shubhjn commited on
Commit
b66181f
·
1 Parent(s): 972f068

add social connect option

Browse files
app/api/ai/generate-content/route.ts ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { generateAIContent } from "@/lib/gemini";
3
+
4
+ export async function POST(req: NextRequest) {
5
+ try {
6
+ const { prompt, type, context } = await req.json();
7
+
8
+ let finalPrompt = "";
9
+ if (type === "caption") {
10
+ finalPrompt = `Write ONE verified, engaging social media caption for a post about: "${prompt}".
11
+ Context: ${context || "None"}.
12
+ Include relevant hashtags. Keep it under 280 characters. Do not provide multiple options.`;
13
+ } else if (type === "tags") {
14
+ finalPrompt = `Generate 15 relevant, high-traffic, trending hashtags for a social media post about: "${prompt}".
15
+ Context: ${context || "None"}.
16
+ Return ONLY a comma-separated list of tags without the '#' symbol. Example: technology, coding, developer. Do not include numbered lists or extra text.`;
17
+ } else {
18
+ finalPrompt = `Generate content for a social media post about: "${prompt}". Context: ${context}.`;
19
+ }
20
+
21
+ const text = await generateAIContent(finalPrompt);
22
+
23
+ return NextResponse.json({ content: text });
24
+ } catch (error) {
25
+ console.error("AI Generation Error:", error);
26
+ return NextResponse.json({ error: "Failed to generate content" }, { status: 500 });
27
+ }
28
+ }
app/api/settings/route.ts CHANGED
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
2
  import { auth } from "@/lib/auth";
3
  import { getEffectiveUserId } from "@/lib/auth-utils";
4
  import { db } from "@/db";
5
- import { users } from "@/db/schema";
6
  import { eq } from "drizzle-orm";
7
 
8
  interface UpdateUserData {
@@ -48,6 +48,11 @@ export async function GET() {
48
  return NextResponse.json({ error: "User not found" }, { status: 404 });
49
  }
50
 
 
 
 
 
 
51
  // Mask sensitive keys for display
52
  const maskedGeminiKey = user.geminiApiKey
53
  ? `••••••••${user.geminiApiKey.slice(-4)}`
@@ -69,6 +74,7 @@ export async function GET() {
69
  website: user.website,
70
  customVariables: user.customVariables,
71
  },
 
72
  });
73
  } catch (error) {
74
  console.error("Error fetching user settings:", error);
 
2
  import { auth } from "@/lib/auth";
3
  import { getEffectiveUserId } from "@/lib/auth-utils";
4
  import { db } from "@/db";
5
+ import { users, connectedAccounts } from "@/db/schema";
6
  import { eq } from "drizzle-orm";
7
 
8
  interface UpdateUserData {
 
48
  return NextResponse.json({ error: "User not found" }, { status: 404 });
49
  }
50
 
51
+ // Fetch connected accounts
52
+ const accounts = await db.query.connectedAccounts.findMany({
53
+ where: eq(connectedAccounts.userId, userId),
54
+ });
55
+
56
  // Mask sensitive keys for display
57
  const maskedGeminiKey = user.geminiApiKey
58
  ? `••••••••${user.geminiApiKey.slice(-4)}`
 
74
  website: user.website,
75
  customVariables: user.customVariables,
76
  },
77
+ connectedAccounts: accounts,
78
  });
79
  } catch (error) {
80
  console.error("Error fetching user settings:", error);
app/api/social/analytics/route.ts ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from "next/server";
2
+ import { auth } from "@/auth";
3
+ import { db } from "@/db";
4
+ import { connectedAccounts } from "@/db/schema";
5
+ import { getEffectiveUserId } from "@/lib/auth-utils";
6
+ import { eq } from "drizzle-orm";
7
+ import { google } from "googleapis";
8
+
9
+ export async function GET() {
10
+ const session = await auth();
11
+ if (!session?.user?.id) {
12
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
13
+ }
14
+ const userId = await getEffectiveUserId(session.user.id);
15
+
16
+ try {
17
+ const accounts = await db.query.connectedAccounts.findMany({
18
+ where: eq(connectedAccounts.userId, userId)
19
+ });
20
+
21
+ const analyticsPromises = accounts.map(async (account) => {
22
+ const stats = {
23
+ platform: account.provider,
24
+ followers: 0,
25
+ reach: 0,
26
+ engagement: 0,
27
+ name: account.name,
28
+ picture: account.picture
29
+ };
30
+
31
+ try {
32
+ if (account.provider === "facebook") {
33
+ // Fetch Page Insights: followers_count, fan_count
34
+ // Note: 'followers_count' is for User, 'fan_count' for Page
35
+ const url = `https://graph.facebook.com/v21.0/${account.providerAccountId}?fields=fan_count,followers_count&access_token=${account.accessToken}`;
36
+ const res = await fetch(url);
37
+ const data = await res.json();
38
+
39
+ if (data.fan_count !== undefined) stats.followers = data.fan_count;
40
+ if (data.followers_count !== undefined) stats.followers = Math.max(stats.followers, data.followers_count);
41
+
42
+ // Mock Reach/Engagement for now as it requires complex insights queries with date ranges
43
+ // In a real app we'd query /insights/page_impressions_unique
44
+ }
45
+ else if (account.provider === "youtube") {
46
+ const oauth2Client = new google.auth.OAuth2(
47
+ process.env.GOOGLE_CLIENT_ID,
48
+ process.env.GOOGLE_CLIENT_SECRET
49
+ );
50
+
51
+ oauth2Client.setCredentials({
52
+ access_token: account.accessToken,
53
+ refresh_token: account.refreshToken,
54
+ expiry_date: account.expiresAt ? new Date(account.expiresAt).getTime() : undefined
55
+ });
56
+
57
+ // Auto-refresh handler
58
+ oauth2Client.on('tokens', async (tokens) => {
59
+ if (tokens.access_token) {
60
+ await db.update(connectedAccounts).set({
61
+ accessToken: tokens.access_token,
62
+ refreshToken: tokens.refresh_token || account.refreshToken, // Keep old refresh token if new one not provided
63
+ expiresAt: tokens.expiry_date ? new Date(tokens.expiry_date) : undefined,
64
+ updatedAt: new Date()
65
+ }).where(eq(connectedAccounts.id, account.id));
66
+ }
67
+ });
68
+
69
+ const youtube = google.youtube({ version: 'v3', auth: oauth2Client });
70
+
71
+ try {
72
+ const res = await youtube.channels.list({
73
+ part: ['statistics'],
74
+ mine: true
75
+ });
76
+
77
+ if (res.data.items && res.data.items.length > 0) {
78
+ const st = res.data.items[0].statistics;
79
+ stats.followers = parseInt(st?.subscriberCount || "0");
80
+ stats.reach = parseInt(st?.viewCount || "0");
81
+ stats.engagement = parseInt(st?.videoCount || "0");
82
+ }
83
+ } catch (apiError: unknown) {
84
+ // Attempt explicit refresh if 401
85
+ // Need to cast to any or a specific error interface to access 'code'
86
+ // Google API errors usually have 'code' or 'response.status'
87
+ const fetchError = apiError as { code?: number; response?: { status?: number } };
88
+ if (fetchError.code === 401 || fetchError.response?.status === 401) {
89
+ console.log("YouTube Token expired, attempting refresh...");
90
+ try {
91
+ const { credentials } = await oauth2Client.refreshAccessToken();
92
+ oauth2Client.setCredentials(credentials);
93
+
94
+ // Retry request
95
+ const res = await youtube.channels.list({
96
+ part: ['statistics'],
97
+ mine: true
98
+ });
99
+
100
+ if (res.data.items && res.data.items.length > 0) {
101
+ const st = res.data.items[0].statistics;
102
+ stats.followers = parseInt(st?.subscriberCount || "0");
103
+ stats.reach = parseInt(st?.viewCount || "0");
104
+ stats.engagement = parseInt(st?.videoCount || "0");
105
+ }
106
+ } catch (refreshError: unknown) {
107
+ console.error("Failed to refresh YouTube token:", refreshError);
108
+ // Optional: access token invalid, maybe disconnect?
109
+ }
110
+ } else {
111
+ throw apiError;
112
+ }
113
+ }
114
+ }
115
+ else if (account.provider === "linkedin") {
116
+ // LinkedIn Profile API - v2/me or organizationalEntityAcls
117
+ // Getting follower count is restricted for personal profiles in v2 API without specific partner programs.
118
+ // We will skip deep stats for personal profiles and just set 0 or mock.
119
+ }
120
+ } catch (e: unknown) {
121
+ console.error(`Failed to fetch stats for ${account.provider}`, e);
122
+ }
123
+
124
+ return stats;
125
+ });
126
+
127
+ const results = await Promise.all(analyticsPromises);
128
+
129
+ // Aggregate
130
+ const aggregated = {
131
+ totalFollowers: results.reduce((acc, curr) => acc + curr.followers, 0),
132
+ totalReach: results.reduce((acc, curr) => acc + curr.reach, 0),
133
+ platforms: results
134
+ };
135
+
136
+ return NextResponse.json(aggregated);
137
+
138
+ } catch (error: unknown) {
139
+ console.error("Analytics Error:", error);
140
+ return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
141
+ }
142
+ }
app/api/social/callback/[provider]/route.ts CHANGED
@@ -112,7 +112,144 @@ export async function GET(
112
  });
113
  }
114
 
115
- return NextResponse.redirect(new URL("/dashboard/social?success=connected", effectiveBaseUrl));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  }
117
 
118
  } catch (err) {
 
112
  });
113
  }
114
 
115
+ return NextResponse.redirect(new URL("/dashboard/settings?success=connected", effectiveBaseUrl));
116
+ }
117
+
118
+ if (provider === "linkedin") {
119
+ const clientId = process.env.LINKEDIN_CLIENT_ID;
120
+ const clientSecret = process.env.LINKEDIN_CLIENT_SECRET;
121
+ const redirectUri = `${effectiveBaseUrl}/api/social/callback/linkedin`;
122
+
123
+ const tokenUrl = "https://www.linkedin.com/oauth/v2/accessToken";
124
+ const params = new URLSearchParams();
125
+ params.append("grant_type", "authorization_code");
126
+ params.append("code", code);
127
+ params.append("redirect_uri", redirectUri);
128
+ params.append("client_id", clientId!);
129
+ params.append("client_secret", clientSecret!);
130
+
131
+ const tokenRes = await fetch(tokenUrl, {
132
+ method: "POST",
133
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
134
+ body: params
135
+ });
136
+ const tokenData = await tokenRes.json();
137
+
138
+ if (tokenData.error) {
139
+ throw new Error(tokenData.error_description || "LinkedIn Auth Failed");
140
+ }
141
+
142
+ const accessToken = tokenData.access_token;
143
+ const expiresSeconds = tokenData.expires_in;
144
+ const expiresAt = new Date(Date.now() + expiresSeconds * 1000);
145
+
146
+ // Fetch Profile (OpenID)
147
+ const meUrl = "https://api.linkedin.com/v2/userinfo";
148
+ const meRes = await fetch(meUrl, {
149
+ headers: { Authorization: `Bearer ${accessToken}` }
150
+ });
151
+ const meData = await meRes.json();
152
+ // meData: { sub: "id", name: "...", picture: "...", email: "..." }
153
+
154
+ // Save to DB
155
+ const existingAccount = await db.query.connectedAccounts.findFirst({
156
+ where: and(
157
+ eq(connectedAccounts.userId, userId),
158
+ eq(connectedAccounts.provider, "linkedin")
159
+ )
160
+ });
161
+
162
+ if (existingAccount) {
163
+ await db.update(connectedAccounts).set({
164
+ accessToken: accessToken,
165
+ expiresAt: expiresAt,
166
+ updatedAt: new Date(),
167
+ name: meData.name,
168
+ picture: meData.picture,
169
+ providerAccountId: meData.sub
170
+ }).where(eq(connectedAccounts.id, existingAccount.id));
171
+ } else {
172
+ await db.insert(connectedAccounts).values({
173
+ userId: userId,
174
+ provider: "linkedin",
175
+ providerAccountId: meData.sub,
176
+ accessToken: accessToken,
177
+ expiresAt: expiresAt,
178
+ name: meData.name,
179
+ picture: meData.picture
180
+ });
181
+ }
182
+
183
+ return NextResponse.redirect(new URL("/dashboard/settings?success=connected", effectiveBaseUrl));
184
+ }
185
+
186
+ if (provider === "youtube") {
187
+ const clientId = process.env.GOOGLE_CLIENT_ID;
188
+ const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
189
+ const redirectUri = `${effectiveBaseUrl}/api/social/callback/youtube`;
190
+
191
+ const tokenUrl = "https://oauth2.googleapis.com/token";
192
+ const params = new URLSearchParams();
193
+ params.append("grant_type", "authorization_code");
194
+ params.append("code", code);
195
+ params.append("redirect_uri", redirectUri);
196
+ params.append("client_id", clientId!);
197
+ params.append("client_secret", clientSecret!);
198
+
199
+ const tokenRes = await fetch(tokenUrl, {
200
+ method: "POST",
201
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
202
+ body: params
203
+ });
204
+ const tokenData = await tokenRes.json();
205
+
206
+ if (tokenData.error) {
207
+ throw new Error(tokenData.error_description || "YouTube Auth Failed");
208
+ }
209
+
210
+ const accessToken = tokenData.access_token;
211
+ // Google tokens expire in 1 hour usually
212
+ const expiresSeconds = tokenData.expires_in;
213
+ const expiresAt = new Date(Date.now() + expiresSeconds * 1000);
214
+
215
+ // Fetch Profile
216
+ const meUrl = "https://www.googleapis.com/oauth2/v2/userinfo";
217
+ const meRes = await fetch(meUrl, {
218
+ headers: { Authorization: `Bearer ${accessToken}` }
219
+ });
220
+ const meData = await meRes.json();
221
+ // meData: { id: "...", email: "...", name: "...", picture: "..." }
222
+
223
+ // Save to DB
224
+ const existingAccount = await db.query.connectedAccounts.findFirst({
225
+ where: and(
226
+ eq(connectedAccounts.userId, userId),
227
+ eq(connectedAccounts.provider, "youtube")
228
+ )
229
+ });
230
+
231
+ if (existingAccount) {
232
+ await db.update(connectedAccounts).set({
233
+ accessToken: accessToken,
234
+ expiresAt: expiresAt,
235
+ updatedAt: new Date(),
236
+ name: meData.name,
237
+ picture: meData.picture,
238
+ providerAccountId: meData.id
239
+ }).where(eq(connectedAccounts.id, existingAccount.id));
240
+ } else {
241
+ await db.insert(connectedAccounts).values({
242
+ userId: userId,
243
+ provider: "youtube",
244
+ providerAccountId: meData.id,
245
+ accessToken: accessToken,
246
+ expiresAt: expiresAt,
247
+ name: meData.name,
248
+ picture: meData.picture
249
+ });
250
+ }
251
+
252
+ return NextResponse.redirect(new URL("/dashboard/settings?success=connected", effectiveBaseUrl));
253
  }
254
 
255
  } catch (err) {
app/api/social/connect/[provider]/route.ts CHANGED
@@ -11,18 +11,17 @@ export async function GET(
11
  }
12
  const { provider } = await params;
13
 
14
- if (provider === "facebook" || provider === "instagram") {
15
- // Both use Facebook Login
16
- // Dynamic Base URL Detection for Spaces/Docker
17
- const host = req.headers.get("x-forwarded-host") || req.headers.get("host");
18
- const protocol = req.headers.get("x-forwarded-proto") || "https"; // Default to https for safety in prod
19
- const baseUrl = host ? `${protocol}://${host}` : process.env.NEXT_PUBLIC_APP_URL;
20
-
21
- // Fallback if configured explicitly, otherwise dynamic
22
- const effectiveBaseUrl = (process.env.NEXT_PUBLIC_APP_URL && !process.env.NEXT_PUBLIC_APP_URL.includes("0.0.0.0"))
23
- ? process.env.NEXT_PUBLIC_APP_URL
24
  : baseUrl;
25
 
 
 
26
  const clientId = process.env.FACEBOOK_CLIENT_ID;
27
  const redirectUri = `${effectiveBaseUrl}/api/social/callback/facebook`;
28
  const state = JSON.stringify({ userId: session.user.id, provider }); // Pass provider to know intent if needed
@@ -42,5 +41,39 @@ export async function GET(
42
  return NextResponse.redirect(url);
43
  }
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  return NextResponse.json({ error: "Invalid provider" }, { status: 400 });
46
  }
 
11
  }
12
  const { provider } = await params;
13
 
14
+ // Dynamic Base URL Detection
15
+ const host = req.headers.get("x-forwarded-host") || req.headers.get("host");
16
+ const protocol = req.headers.get("x-forwarded-proto") || "https";
17
+ const baseUrl = host ? `${protocol}://${host}` : process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
18
+
19
+ const effectiveBaseUrl = (process.env.NEXT_PUBLIC_APP_URL && !process.env.NEXT_PUBLIC_APP_URL.includes("0.0.0.0"))
20
+ ? process.env.NEXT_PUBLIC_APP_URL
 
 
 
21
  : baseUrl;
22
 
23
+ if (provider === "facebook" || provider === "instagram") {
24
+ // Both use Facebook Login
25
  const clientId = process.env.FACEBOOK_CLIENT_ID;
26
  const redirectUri = `${effectiveBaseUrl}/api/social/callback/facebook`;
27
  const state = JSON.stringify({ userId: session.user.id, provider }); // Pass provider to know intent if needed
 
41
  return NextResponse.redirect(url);
42
  }
43
 
44
+ if (provider === "linkedin") {
45
+ const clientId = process.env.LINKEDIN_CLIENT_ID;
46
+ const redirectUri = `${effectiveBaseUrl}/api/social/callback/linkedin`;
47
+ const state = JSON.stringify({ userId: session.user.id, provider });
48
+
49
+ // LinkedIn Scopes (v2)
50
+ // w_member_social: Create posts
51
+ // openid, profile, email: Authentication
52
+ const scope = ["openid", "profile", "w_member_social", "email"].join(" "); // space separated
53
+
54
+ const url = `https://www.linkedin.com/oauth/v2/authorization?response_type=code&client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&state=${encodeURIComponent(state)}&scope=${encodeURIComponent(scope)}`;
55
+
56
+ return NextResponse.redirect(url);
57
+ }
58
+
59
+ if (provider === "youtube") {
60
+ const clientId = process.env.GOOGLE_CLIENT_ID;
61
+ const redirectUri = `${effectiveBaseUrl}/api/social/callback/youtube`;
62
+ const state = JSON.stringify({ userId: session.user.id, provider });
63
+
64
+ // YouTube Scopes
65
+ const scope = [
66
+ "https://www.googleapis.com/auth/userinfo.email",
67
+ "https://www.googleapis.com/auth/userinfo.profile",
68
+ "https://www.googleapis.com/auth/youtube.readonly",
69
+ "https://www.googleapis.com/auth/youtube.upload",
70
+ "https://www.googleapis.com/auth/youtube" // Full access managed
71
+ ].join(" ");
72
+
73
+ const url = `https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&state=${encodeURIComponent(state)}&scope=${encodeURIComponent(scope)}&access_type=offline&prompt=consent`;
74
+
75
+ return NextResponse.redirect(url);
76
+ }
77
+
78
  return NextResponse.json({ error: "Invalid provider" }, { status: 400 });
79
  }
app/api/social/posts/create/route.ts CHANGED
@@ -3,7 +3,8 @@ import { auth } from "@/auth";
3
  import { db } from "@/db";
4
  import { socialPosts, connectedAccounts } from "@/db/schema";
5
  import { eq } from "drizzle-orm";
6
- import { publishToFacebook, publishToInstagram } from "@/lib/social/publisher";
 
7
 
8
  export async function POST(req: NextRequest) {
9
  const session = await auth();
@@ -37,7 +38,7 @@ export async function POST(req: NextRequest) {
37
  accessToken: account.accessToken,
38
  providerAccountId: account.providerAccountId,
39
  content,
40
- mediaUrls
41
  });
42
  } else if (platform === "instagram") {
43
  // Find IG Account ID linked (for now assume providerAccountId IS the target or we query it)
@@ -47,7 +48,7 @@ export async function POST(req: NextRequest) {
47
  accessToken: account.accessToken,
48
  providerAccountId: account.providerAccountId,
49
  content,
50
- mediaUrls
51
  });
52
  }
53
 
 
3
  import { db } from "@/db";
4
  import { socialPosts, connectedAccounts } from "@/db/schema";
5
  import { eq } from "drizzle-orm";
6
+ import { socialPublisher } from "@/lib/social/publisher";
7
+ const { publishToFacebook, publishToInstagram } = socialPublisher;
8
 
9
  export async function POST(req: NextRequest) {
10
  const session = await auth();
 
38
  accessToken: account.accessToken,
39
  providerAccountId: account.providerAccountId,
40
  content,
41
+ mediaUrl: mediaUrls && mediaUrls.length > 0 ? mediaUrls[0] : undefined
42
  });
43
  } else if (platform === "instagram") {
44
  // Find IG Account ID linked (for now assume providerAccountId IS the target or we query it)
 
48
  accessToken: account.accessToken,
49
  providerAccountId: account.providerAccountId,
50
  content,
51
+ mediaUrl: mediaUrls && mediaUrls.length > 0 ? mediaUrls[0] : undefined
52
  });
53
  }
54
 
app/api/social/posts/route.ts ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { auth } from "@/auth";
3
+ import { db } from "@/db";
4
+ import { socialPosts, connectedAccounts } from "@/db/schema";
5
+ import { getEffectiveUserId } from "@/lib/auth-utils";
6
+ import { eq, and } from "drizzle-orm";
7
+ import { nanoid } from "nanoid";
8
+ import { socialPublisher } from "@/lib/social/publisher";
9
+
10
+ export async function GET() {
11
+ const session = await auth();
12
+ if (!session?.user?.id) {
13
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
14
+ }
15
+ const userId = await getEffectiveUserId(session.user.id);
16
+
17
+ const posts = await db.query.socialPosts.findMany({
18
+ where: eq(socialPosts.userId, userId),
19
+ orderBy: (posts, { desc }) => [desc(posts.createdAt)],
20
+ with: {
21
+ // We might want to join with connectedAccount to see which page it was posted to
22
+ }
23
+ });
24
+
25
+ return NextResponse.json({ posts });
26
+ }
27
+
28
+ export async function POST(req: NextRequest) {
29
+ const session = await auth();
30
+ if (!session?.user?.id) {
31
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
32
+ }
33
+ const userId = await getEffectiveUserId(session.user.id);
34
+ if (!userId) {
35
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
36
+ }
37
+
38
+ try {
39
+ const body = await req.json();
40
+ const { content, mediaUrl, platforms, title, thumbnailUrl, tags, category, scheduledAt } = body;
41
+ // platforms: array of connectedAccount Ids
42
+
43
+ if (!content && !mediaUrl && !title) {
44
+ return NextResponse.json({ error: "Content, Title, or Media required" }, { status: 400 });
45
+ }
46
+ if (!platforms || !Array.isArray(platforms) || platforms.length === 0) {
47
+ return NextResponse.json({ error: "Select at least one platform" }, { status: 400 });
48
+ }
49
+
50
+ const results = [];
51
+
52
+ for (const accountId of platforms) {
53
+ // Fetch account details
54
+ const account = await db.query.connectedAccounts.findFirst({
55
+ where: eq(connectedAccounts.id, accountId)
56
+ });
57
+
58
+ if (!account) {
59
+ results.push({ accountId, status: "failed", error: "Account not found" });
60
+ continue;
61
+ }
62
+
63
+ // Create DB Entry
64
+ const postId = nanoid();
65
+ const isScheduled = !!scheduledAt;
66
+
67
+ await db.insert(socialPosts).values({
68
+ id: postId,
69
+ userId,
70
+ connectedAccountId: accountId,
71
+ content: content,
72
+ title: title,
73
+ thumbnailUrl: thumbnailUrl,
74
+ tags: tags,
75
+ category: category,
76
+ mediaUrls: mediaUrl ? [mediaUrl] : [],
77
+ scheduledAt: isScheduled ? new Date(scheduledAt) : null,
78
+ status: isScheduled ? "scheduled" : "publishing",
79
+ platform: account.provider,
80
+ createdAt: new Date(),
81
+ updatedAt: new Date()
82
+ });
83
+
84
+ // If scheduled, skip publishing
85
+ if (isScheduled) {
86
+ results.push({ accountId, status: "scheduled", postId });
87
+ continue;
88
+ }
89
+
90
+ // Trigger Publish
91
+ try {
92
+ let platformPostId = null;
93
+ const fullMediaUrl = mediaUrl ? (mediaUrl.startsWith("http") ? mediaUrl : `${process.env.NEXT_PUBLIC_APP_URL}${mediaUrl}`) : undefined;
94
+
95
+ const payload = {
96
+ content,
97
+ title, // Pass title
98
+ mediaUrl: fullMediaUrl,
99
+ accessToken: account.accessToken,
100
+ providerAccountId: account.providerAccountId,
101
+ refreshToken: account.refreshToken || undefined
102
+ };
103
+
104
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
105
+ const provider = account.provider as any;
106
+
107
+ if (provider === "facebook") {
108
+ platformPostId = await socialPublisher.publishToFacebook(payload);
109
+ } else if (provider === "instagram") {
110
+ const metadata = account.metadata as Record<string, unknown> | null;
111
+ if (provider === "facebook" && metadata?.type === "instagram") {
112
+ platformPostId = await socialPublisher.publishToFacebook(payload);
113
+ } else {
114
+ platformPostId = await socialPublisher.publishToFacebook(payload);
115
+ }
116
+ } else if (provider === "linkedin") {
117
+ platformPostId = await socialPublisher.publishToLinkedin(payload);
118
+ } else if (provider === "youtube") {
119
+ platformPostId = await socialPublisher.publishToYoutube(payload);
120
+ }
121
+
122
+ // Update DB Success
123
+ await db.update(socialPosts).set({
124
+ status: "published",
125
+ platformPostId: platformPostId,
126
+ publishedAt: new Date()
127
+ }).where(eq(socialPosts.id, postId));
128
+
129
+ results.push({ accountId, status: "published", postId: platformPostId });
130
+
131
+ } catch (err: unknown) {
132
+ const errorMessage = err instanceof Error ? err.message : "Unknown error";
133
+ console.error(`Publish failed for ${account.provider}:`, err);
134
+ // Update DB Fail
135
+ await db.update(socialPosts).set({
136
+ status: "failed",
137
+ error: errorMessage
138
+ }).where(eq(socialPosts.id, postId));
139
+
140
+ results.push({ accountId, status: "failed", error: errorMessage });
141
+ }
142
+ }
143
+
144
+ return NextResponse.json({ success: true, results });
145
+
146
+ } catch (error) {
147
+ console.error("Create Post Error:", error);
148
+ return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
149
+ }
150
+ }
151
+
152
+ export async function DELETE(req: NextRequest) {
153
+ const session = await auth();
154
+ if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
155
+ const userId = await getEffectiveUserId(session.user.id);
156
+
157
+ const { searchParams } = new URL(req.url);
158
+ const id = searchParams.get("id");
159
+
160
+ if (!id) return NextResponse.json({ error: "ID required" }, { status: 400 });
161
+
162
+ try {
163
+ await db.delete(socialPosts).where(and(eq(socialPosts.id, id), eq(socialPosts.userId, userId)));
164
+ return NextResponse.json({ success: true });
165
+ } catch (error) {
166
+ console.error("Delete Post Error:", error);
167
+ return NextResponse.json({ error: "Failed to delete post" }, { status: 500 });
168
+ }
169
+ }
app/api/social/youtube/playlists/route.ts ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { auth } from "@/auth";
3
+ import { db } from "@/db";
4
+ import { connectedAccounts } from "@/db/schema";
5
+ import { getEffectiveUserId } from "@/lib/auth-utils";
6
+ import { eq, and } from "drizzle-orm";
7
+ import { google } from "googleapis";
8
+
9
+ // Helper to get authenticated YouTube client
10
+ async function getYouTubeClient(userId: string) {
11
+ const account = await db.query.connectedAccounts.findFirst({
12
+ where: and(
13
+ eq(connectedAccounts.userId, userId),
14
+ eq(connectedAccounts.provider, "youtube")
15
+ )
16
+ });
17
+
18
+ if (!account) {
19
+ throw new Error("YouTube account not connected");
20
+ }
21
+
22
+ const oauth2Client = new google.auth.OAuth2(
23
+ process.env.GOOGLE_CLIENT_ID,
24
+ process.env.GOOGLE_CLIENT_SECRET
25
+ );
26
+
27
+ oauth2Client.setCredentials({
28
+ access_token: account.accessToken,
29
+ refresh_token: account.refreshToken,
30
+ expiry_date: account.expiresAt ? new Date(account.expiresAt).getTime() : undefined
31
+ });
32
+
33
+ // Auto-refresh handler
34
+ oauth2Client.on('tokens', async (tokens) => {
35
+ if (tokens.access_token) {
36
+ await db.update(connectedAccounts).set({
37
+ accessToken: tokens.access_token,
38
+ refreshToken: tokens.refresh_token || account.refreshToken,
39
+ expiresAt: tokens.expiry_date ? new Date(tokens.expiry_date) : undefined,
40
+ updatedAt: new Date()
41
+ }).where(eq(connectedAccounts.id, account.id));
42
+ }
43
+ });
44
+
45
+ return google.youtube({ version: 'v3', auth: oauth2Client });
46
+ }
47
+
48
+ export async function GET() {
49
+ const session = await auth();
50
+ if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
51
+ const userId = await getEffectiveUserId(session.user.id);
52
+
53
+ try {
54
+ const youtube = await getYouTubeClient(userId);
55
+
56
+ // Handle 401/Refresh logic wrapper could be extracted, but for now relying on library + simple retry if needed
57
+ // The library handles auto-refresh if refresh_token is set (which we do).
58
+
59
+ const response = await youtube.playlists.list({
60
+ part: ['snippet', 'contentDetails'],
61
+ mine: true,
62
+ maxResults: 50
63
+ });
64
+
65
+ const playlists = response.data.items?.map(item => ({
66
+ id: item.id,
67
+ title: item.snippet?.title,
68
+ thumbnail: item.snippet?.thumbnails?.default?.url
69
+ })) || [];
70
+
71
+ return NextResponse.json({ playlists });
72
+
73
+ } catch (error: unknown) {
74
+ console.error("Fetch Playlists Error:", error);
75
+ const errorMessage = error instanceof Error ? error.message : "Failed to fetch playlists";
76
+ return NextResponse.json({ error: errorMessage }, { status: 500 });
77
+ }
78
+ }
79
+
80
+ export async function POST(req: NextRequest) {
81
+ const session = await auth();
82
+ if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
83
+ const userId = await getEffectiveUserId(session.user.id);
84
+
85
+ try {
86
+ const { title, description } = await req.json();
87
+
88
+ if (!title) return NextResponse.json({ error: "Title is required" }, { status: 400 });
89
+
90
+ const youtube = await getYouTubeClient(userId);
91
+
92
+ const response = await youtube.playlists.insert({
93
+ part: ['snippet', 'status'],
94
+ requestBody: {
95
+ snippet: {
96
+ title: title,
97
+ description: description || "Created via AutoLoop"
98
+ },
99
+ status: {
100
+ privacyStatus: 'public' // Default to public or private? 'public' is usually desired for social tools
101
+ }
102
+ }
103
+ });
104
+
105
+ const newPlaylist = {
106
+ id: response.data.id,
107
+ title: response.data.snippet?.title,
108
+ thumbnail: response.data.snippet?.thumbnails?.default?.url
109
+ };
110
+
111
+ return NextResponse.json({ playlist: newPlaylist });
112
+
113
+ } catch (error: unknown) {
114
+ console.error("Create Playlist Error:", error);
115
+ const errorMessage = error instanceof Error ? error.message : "Failed to create playlist";
116
+ return NextResponse.json({ error: errorMessage }, { status: 500 });
117
+ }
118
+ }
app/api/upload/route.ts ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { writeFile, mkdir } from "fs/promises";
3
+ import path from "path";
4
+ import { nanoid } from "nanoid";
5
+
6
+ export async function POST(req: NextRequest) {
7
+ try {
8
+ const formData = await req.formData();
9
+ const file = formData.get("file") as File;
10
+
11
+ if (!file) {
12
+ return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
13
+ }
14
+
15
+ const bytes = await file.arrayBuffer();
16
+ const buffer = Buffer.from(bytes);
17
+
18
+ // Create uploads directory if it doesn't exist
19
+ const uploadDir = path.join(process.cwd(), "public/uploads");
20
+ try {
21
+ await mkdir(uploadDir, { recursive: true });
22
+ } catch (e) {
23
+ // Ignore if exists
24
+ }
25
+
26
+ // Generate unique filename
27
+ const ext = path.extname(file.name);
28
+ const filename = `${nanoid()}${ext}`;
29
+ const filepath = path.join(uploadDir, filename);
30
+
31
+ await writeFile(filepath, buffer);
32
+
33
+ const publicUrl = `/uploads/${filename}`;
34
+
35
+ return NextResponse.json({ url: publicUrl, filename: file.name });
36
+ } catch (error) {
37
+ console.error("Upload error:", error);
38
+ return NextResponse.json({ error: "Upload failed" }, { status: 500 });
39
+ }
40
+ }
app/dashboard/businesses/page.tsx CHANGED
@@ -54,7 +54,7 @@ export default function BusinessesPage() {
54
 
55
  const [categories, setCategories] = useState<string[]>([]);
56
 
57
- const { get: getBusinessesApi } = useApi<{ businesses: Business[], totalPages: number, page: number }>();
58
  const { get: getCategoriesApi } = useApi<{ categories: string[] }>();
59
 
60
  // Debounce effect
@@ -235,6 +235,7 @@ export default function BusinessesPage() {
235
  onSendEmail={handleSendEmail}
236
  selectedIds={selectedIds}
237
  onSelectionChange={setSelectedIds}
 
238
  />
239
 
240
  {/* Pagination Controls */}
 
54
 
55
  const [categories, setCategories] = useState<string[]>([]);
56
 
57
+ const { get: getBusinessesApi, loading: loadingBusinesses } = useApi<{ businesses: Business[], totalPages: number, page: number }>();
58
  const { get: getCategoriesApi } = useApi<{ categories: string[] }>();
59
 
60
  // Debounce effect
 
235
  onSendEmail={handleSendEmail}
236
  selectedIds={selectedIds}
237
  onSelectionChange={setSelectedIds}
238
+ isLoading={loadingBusinesses}
239
  />
240
 
241
  {/* Pagination Controls */}
app/dashboard/page.tsx CHANGED
@@ -1,48 +1,27 @@
1
  "use client";
2
 
3
- import { useState, useEffect, useCallback } from "react";
4
  import { StatCard } from "@/components/dashboard/stat-card";
5
  import { BusinessTable } from "@/components/dashboard/business-table";
6
- import { BusinessDetailModal } from "@/components/dashboard/business-detail-modal";
7
- import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
8
  import { Button } from "@/components/ui/button";
9
- import { Input } from "@/components/ui/input";
10
- import { Label } from "@/components/ui/label";
11
- import { Checkbox } from "@/components/ui/checkbox";
12
  import { Business } from "@/types";
13
- import {
14
- Users,
15
- Mail,
16
- TrendingUp,
17
- Play,
18
- Loader2,
19
- Sparkles,
20
- } from "lucide-react";
21
- import { BusinessTypeSelect } from "@/components/business-type-select";
22
- import { KeywordInput } from "@/components/keyword-input";
23
- import { ActiveTaskCard } from "@/components/active-task-card";
24
- // Recharts moved to separate component
25
  import dynamic from "next/dynamic";
26
  import { AnimatedContainer } from "@/components/animated-container";
 
 
 
27
 
28
  const EmailChart = dynamic(() => import("@/components/dashboard/email-chart"), {
29
- loading: () => <div className="h-[300px] flex items-center justify-center"><Loader2 className="animate-spin h-6 w-6" /></div>,
30
  ssr: false
31
  });
32
- import { useApi } from "@/hooks/use-api";
33
- import { toast } from "sonner";
34
- import { allLocations } from "@/lib/locations";
35
- import { sendNotification } from "@/components/notification-bell";
36
 
37
  interface DashboardStats {
38
  totalBusinesses: number;
39
- totalTemplates: number;
40
- totalWorkflows: number;
41
  emailsSent: number;
42
- emailsOpened: number;
43
- emailsClicked: number;
44
  openRate: number;
45
- clickRate: number;
46
  quotaUsed: number;
47
  quotaLimit: number;
48
  }
@@ -53,386 +32,64 @@ interface ChartDataPoint {
53
  opened: number;
54
  }
55
 
56
- interface ActiveTask {
57
- jobId: string;
58
- workflowName: string;
59
- status: string;
60
- businessesFound: number;
61
- }
62
-
63
  export default function DashboardPage() {
64
  const [businesses, setBusinesses] = useState<Business[]>([]);
65
- const [selectedBusiness, setSelectedBusiness] = useState<Business | null>(null);
66
- const [isModalOpen, setIsModalOpen] = useState(false);
67
- const [keywords, setKeywords] = useState<string[]>([]);
68
- const [isScrapingStarted, setIsScrapingStarted] = useState(false);
69
- const [isGeneratingKeywords, setIsGeneratingKeywords] = useState(false);
70
- const [activeTask, setActiveTask] = useState<ActiveTask | null>(null);
71
  const [stats, setStats] = useState<DashboardStats | null>(null);
72
  const [chartData, setChartData] = useState<ChartDataPoint[]>([]);
73
- const [businessType, setBusinessType] = useState<string>("");
74
- const [location, setLocation] = useState<string>("");
75
- const [scrapingSources, setScrapingSources] = useState<string[]>(["google-maps", "google-search"]);
76
-
77
- const handleViewDetails = (business: Business) => {
78
- setSelectedBusiness(business);
79
- setIsModalOpen(true);
80
- };
81
 
82
  // API Hooks
83
- const { post: startScraping } = useApi();
84
- const { get: getBusinessesApi } = useApi<{ businesses: Business[] }>();
85
- // Use loadingStats from hook instead of local state
86
  const { get: getStatsApi, loading: loadingStats } = useApi<{ stats: DashboardStats; chartData: ChartDataPoint[] }>();
87
- const { post: generateKeywords } = useApi<{ keywords: string[] }>();
88
- const { get: getActiveTasks } = useApi<{ tasks: Array<{ id: string; type: string; status: string; workflowName?: string; businessesFound?: number }> }>();
89
-
90
- const fetchDashboardStats = useCallback(async () => {
91
- const data = await getStatsApi("/api/dashboard/stats");
92
- if (data) {
93
- setStats(data.stats);
94
- setChartData(data.chartData || []);
95
- }
96
- }, [getStatsApi]);
97
-
98
- const fetchActiveTask = useCallback(async () => {
99
- const data = await getActiveTasks("/api/tasks");
100
- if (data?.tasks) {
101
- // Find first active scraping task
102
- const activeJob = data.tasks.find(
103
- (task: { type: string; status: string }) =>
104
- task.type === "scraping" &&
105
- (task.status === "processing" || task.status === "paused")
106
- );
107
-
108
- if (activeJob) {
109
- setActiveTask({
110
- jobId: activeJob.id,
111
- workflowName: activeJob.workflowName || "Scraping Job",
112
- status: activeJob.status,
113
- businessesFound: activeJob.businessesFound || 0,
114
- });
115
- } else if (activeTask) {
116
- // Clear if task completed
117
- setActiveTask(null);
118
- }
119
- }
120
- }, [getActiveTasks, activeTask]);
121
 
122
  useEffect(() => {
123
- const initData = async () => {
124
- // Fetch businesses
125
- const businessData = await getBusinessesApi("/api/businesses");
126
- if (businessData) {
127
- setBusinesses(businessData.businesses || []);
 
 
 
 
 
128
  }
129
-
130
- // Fetch stats
131
- await fetchDashboardStats();
132
-
133
- // Fetch active task
134
- await fetchActiveTask();
135
- };
136
-
137
- initData();
138
-
139
- // Auto-refresh stats every 30 seconds
140
- const statsInterval = setInterval(fetchDashboardStats, 30000);
141
-
142
- // Auto-refresh active task every 5 seconds
143
- const taskInterval = setInterval(fetchActiveTask, 5000);
144
-
145
- return () => {
146
- clearInterval(statsInterval);
147
- clearInterval(taskInterval);
148
  };
149
- }, [getBusinessesApi, fetchDashboardStats, fetchActiveTask]);
150
-
151
- const { post: sendEmailApi } = useApi();
152
-
153
- const handleSendEmail = async (business: Business) => {
154
- const toastId = toast.loading(`Sending email to ${business.name}...`);
155
- try {
156
- const result = await sendEmailApi("/api/email/send", { businessId: business.id });
157
-
158
- if (!result) {
159
- throw new Error("Failed to send email");
160
- }
161
-
162
- toast.success(`Email sent to ${business.email}`, { id: toastId });
163
-
164
- setBusinesses(prev => prev.map(b =>
165
- b.id === business.id
166
- ? { ...b, emailStatus: "sent", emailSent: true }
167
- : b
168
- ));
169
- } catch {
170
- toast.error("Failed to send email", { id: toastId });
171
- }
172
- };
173
-
174
- const handleStartScraping = async () => {
175
- if (!businessType || !location) return;
176
-
177
- setIsScrapingStarted(true);
178
- try {
179
- const result = await startScraping("/api/scraping/start", {
180
- targetBusinessType: businessType,
181
- keywords,
182
- location,
183
- sources: scrapingSources, // Pass selected sources
184
- });
185
-
186
- if (result) {
187
- sendNotification({
188
- title: "Scraping job started!",
189
- message: "Check the Tasks page to monitor progress.",
190
- type: "success",
191
- link: "/dashboard/tasks",
192
- actionLabel: "View Tasks"
193
- });
194
-
195
- // Refresh businesses list after a short delay
196
- setTimeout(async () => {
197
- const businessData = await getBusinessesApi("/api/businesses");
198
- if (businessData) {
199
- setBusinesses(businessData.businesses || []);
200
- }
201
- }, 2000);
202
- }
203
- } catch (error) {
204
- toast.error("Failed to start scraping", {
205
- description: "Please try again or check your connection.",
206
- });
207
- console.error("Error starting scraping:", error);
208
- } finally {
209
- setIsScrapingStarted(false);
210
- }
211
- };
212
-
213
- const handleGenerateKeywords = async () => {
214
- if (!businessType) {
215
- toast.error("Please select a business type first");
216
- return;
217
- }
218
-
219
- setIsGeneratingKeywords(true);
220
- try {
221
- const result = await generateKeywords("/api/keywords/generate", {
222
- businessType,
223
- });
224
-
225
- if (result?.keywords) {
226
- // Merge with existing keywords, avoiding duplicates
227
- const newKeywords = result.keywords.filter(
228
- (kw: string) => !keywords.includes(kw)
229
- );
230
- setKeywords([...keywords, ...newKeywords]);
231
- sendNotification({
232
- title: "Keywords Generated",
233
- message: `Successfully generated ${newKeywords.length} new keywords.`,
234
- type: "success"
235
- });
236
- }
237
- } catch (error) {
238
- toast.error("Failed to generate keywords");
239
- console.error("Error generating keywords:", error);
240
- } finally {
241
- setIsGeneratingKeywords(false);
242
- }
243
- };
244
 
245
  return (
246
  <div className="space-y-6">
247
- {/* Scraping Form */}
248
- <Card>
249
- <CardHeader>
250
- <CardTitle>Start New Search</CardTitle>
251
- <CardDescription>
252
- Find local businesses and automatically reach out to them
253
- </CardDescription>
254
- </CardHeader>
255
- <CardContent className="space-y-4">
256
- <div className="grid gap-4 md:grid-cols-2">
257
- <div className="space-y-2">
258
- <Label htmlFor="businessType">Business Type</Label>
259
- <BusinessTypeSelect
260
- value={businessType}
261
- onValueChange={setBusinessType}
262
- />
263
- </div>
264
- <div className="space-y-2">
265
- <Label htmlFor="location">Location</Label>
266
- <Input
267
- id="location"
268
- list="locations"
269
- placeholder="e.g., New York, NY"
270
- value={location}
271
- onChange={(e) => setLocation(e.target.value)}
272
- />
273
- <datalist id="locations">
274
- {allLocations.map((loc) => (
275
- <option key={loc} value={loc} />
276
- ))}
277
- </datalist>
278
- </div>
279
- </div>
280
- <div className="space-y-2">
281
- <div className="flex items-center justify-between">
282
- <Label htmlFor="keywords">Keywords (Optional)</Label>
283
- <Button
284
- type="button"
285
- variant="outline"
286
- size="icon"
287
- onClick={handleGenerateKeywords}
288
- disabled={!businessType || isGeneratingKeywords}
289
- className="gap-2"
290
- >
291
- {isGeneratingKeywords ? (
292
- <>
293
- <Loader2 className="h-4 w-4 animate-spin" />
294
- </>
295
- ) : (
296
- <>
297
- <Sparkles className="h-4 w-4" />
298
- </>
299
- )}
300
- </Button>
301
- </div>
302
- <KeywordInput
303
- businessTypeId={businessType}
304
- value={keywords}
305
- onChange={setKeywords}
306
- placeholder="Add relevant keywords..."
307
- />
308
- <p className="text-sm text-muted-foreground">
309
- Press Enter to add custom keywords, click suggestions, or use AI to generate
310
- </p>
311
- </div>
312
-
313
- {/* Scraping Sources Selection */}
314
- <div className="space-y-2">
315
- <Label>Scraping Sources</Label>
316
- <div className="flex flex-wrap gap-3">
317
- <label className="flex items-center gap-2 cursor-pointer">
318
- <Checkbox
319
- checked={scrapingSources.includes("google-maps")}
320
- onCheckedChange={(checked) => {
321
- if (checked) setScrapingSources([...scrapingSources, "google-maps"]);
322
- else setScrapingSources(scrapingSources.filter(s => s !== "google-maps"));
323
- }}
324
- />
325
- <span className="text-sm">📍 Google Maps</span>
326
- </label>
327
- <label className="flex items-center gap-2 cursor-pointer">
328
- <Checkbox
329
- checked={scrapingSources.includes("google-search")}
330
- onCheckedChange={(checked) => {
331
- if (checked) setScrapingSources([...scrapingSources, "google-search"]);
332
- else setScrapingSources(scrapingSources.filter(s => s !== "google-search"));
333
- }}
334
- />
335
- <span className="text-sm">🔍 Google Search</span>
336
- </label>
337
- <label className="flex items-center gap-2 cursor-pointer">
338
- <Checkbox
339
- checked={scrapingSources.includes("linkedin")}
340
- onCheckedChange={(checked) => {
341
- if (checked) setScrapingSources([...scrapingSources, "linkedin"]);
342
- else setScrapingSources(scrapingSources.filter(s => s !== "linkedin"));
343
- }}
344
- />
345
- <span className="text-sm">💼 LinkedIn</span>
346
- </label>
347
- <label className="flex items-center gap-2 cursor-pointer">
348
- <Checkbox
349
- checked={scrapingSources.includes("facebook")}
350
- onCheckedChange={(checked) => {
351
- if (checked) setScrapingSources([...scrapingSources, "facebook"]);
352
- else setScrapingSources(scrapingSources.filter(s => s !== "facebook"));
353
- }}
354
- />
355
- <span className="text-sm">👥 Facebook</span>
356
- </label>
357
- <label className="flex items-center gap-2 cursor-pointer">
358
- <Checkbox
359
- checked={scrapingSources.includes("instagram")}
360
- onCheckedChange={(checked) => {
361
- if (checked) setScrapingSources([...scrapingSources, "instagram"]);
362
- else setScrapingSources(scrapingSources.filter(s => s !== "instagram"));
363
- }}
364
- />
365
- <span className="text-sm">📸 Instagram</span>
366
- </label>
367
- </div>
368
- <p className="text-xs text-muted-foreground">
369
- Select sources to scrape from. More sources = more comprehensive results.
370
- </p>
371
- </div>
372
- <Button
373
- onClick={handleStartScraping}
374
- disabled={isScrapingStarted || !businessType || !location}
375
- className="w-full cursor-pointer"
376
- >
377
- {isScrapingStarted ? (
378
- <>
379
- <Loader2 className="mr-2 h-4 w-4 animate-spin" />
380
- Scraping...
381
- </>
382
- ) : (
383
- <>
384
- <Play className="mr-2 h-4 w-4" />
385
- Start Scraping
386
- </>
387
- )}
388
  </Button>
 
 
389
 
390
- {/* Active Task Card - Shown here when scraping */}
391
- {activeTask && (
392
- <ActiveTaskCard
393
- jobId={activeTask.jobId}
394
- workflowName={activeTask.workflowName}
395
- status={activeTask.status}
396
- businessesFound={activeTask.businessesFound}
397
- onDismiss={() => setActiveTask(null)}
398
- onStatusChange={fetchActiveTask}
399
- />
400
- )}
401
- </CardContent>
402
- </Card>
403
-
404
- {/* Stats */}
405
  <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
406
  {loadingStats || !stats ? (
407
  Array.from({ length: 4 }).map((_, i) => (
408
- <AnimatedContainer key={i} delay={0.1 + (i * 0.1)}>
409
- <Card>
410
- <CardHeader>
411
- <div className="h-4 w-24 bg-muted animate-pulse rounded"></div>
412
- </CardHeader>
413
- <CardContent>
414
- <div className="h-8 w-16 bg-muted animate-pulse rounded"></div>
415
- </CardContent>
416
- </Card>
417
- </AnimatedContainer>
418
  ))
419
  ) : (
420
- <>
 
 
 
421
  <AnimatedContainer delay={0.2}>
422
- <StatCard
423
- title="Total Businesses"
424
- value={stats.totalBusinesses}
425
- icon={Users}
426
- />
427
  </AnimatedContainer>
428
  <AnimatedContainer delay={0.3}>
429
- <StatCard
430
- title="Emails Sent"
431
- value={stats.emailsSent}
432
- icon={Mail}
433
- />
434
- </AnimatedContainer>
435
- <AnimatedContainer delay={0.4}>
436
  <Card>
437
  <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
438
  <CardTitle className="text-sm font-medium">Daily Quota</CardTitle>
@@ -440,9 +97,6 @@ export default function DashboardPage() {
440
  </CardHeader>
441
  <CardContent>
442
  <div className="text-2xl font-bold">{stats.quotaUsed} / {stats.quotaLimit}</div>
443
- <p className="text-xs text-muted-foreground">
444
- {Math.round((stats.quotaUsed / stats.quotaLimit) * 100)}% used
445
- </p>
446
  <div className="mt-3 h-2 w-full bg-secondary rounded-full overflow-hidden">
447
  <div
448
  className={`h-full ${stats.quotaUsed >= stats.quotaLimit ? 'bg-red-500' : 'bg-blue-500'}`}
@@ -452,53 +106,56 @@ export default function DashboardPage() {
452
  </CardContent>
453
  </Card>
454
  </AnimatedContainer>
455
- <AnimatedContainer delay={0.5}>
456
- <StatCard
457
- title="Open Rate"
458
- value={`${stats.openRate}% `}
459
- icon={TrendingUp}
460
- />
461
- </AnimatedContainer>
462
- </>
463
- )}
464
- </div>
465
-
466
- {/* Chart */}
467
- <AnimatedContainer delay={0.6}>
468
- <Card>
469
- <CardHeader>
470
- <CardTitle>Email Performance (Last 7 Days)</CardTitle>
471
- </CardHeader>
472
- <CardContent>
473
  <EmailChart data={chartData} loading={loadingStats} />
474
- </CardContent>
475
- </Card>
476
- </AnimatedContainer>
 
 
 
 
 
 
 
 
 
 
 
 
 
477
 
478
- {/* Business Table */}
479
- <Card>
480
  <CardHeader className="flex flex-row items-center justify-between">
481
- <CardTitle>Recent Leads ({businesses.length})</CardTitle>
482
- <Button variant="outline" asChild>
483
- <a href="/dashboard/businesses">View All</a>
484
  </Button>
485
- </CardHeader>
486
- <CardContent>
487
  <BusinessTable
488
- businesses={businesses.slice(0, 10)}
489
- onViewDetails={handleViewDetails}
490
- onSendEmail={handleSendEmail}
 
491
  />
492
- </CardContent>
493
- </Card>
494
-
495
- {/* Detail Modal */}
496
- <BusinessDetailModal
497
- business={selectedBusiness}
498
- isOpen={isModalOpen}
499
- onClose={() => setIsModalOpen(false)}
500
- onSendEmail={handleSendEmail}
501
- />
502
- </div>
503
- );
504
  }
 
1
  "use client";
2
 
3
+ import { useState, useEffect } from "react";
4
  import { StatCard } from "@/components/dashboard/stat-card";
5
  import { BusinessTable } from "@/components/dashboard/business-table";
6
+ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
 
7
  import { Button } from "@/components/ui/button";
 
 
 
8
  import { Business } from "@/types";
9
+ import { Users, Mail, TrendingUp, ArrowRight } from "lucide-react";
 
 
 
 
 
 
 
 
 
 
 
10
  import dynamic from "next/dynamic";
11
  import { AnimatedContainer } from "@/components/animated-container";
12
+ import { useApi } from "@/hooks/use-api";
13
+ import Link from "next/link";
14
+ import { Skeleton } from "@/components/ui/skeleton";
15
 
16
  const EmailChart = dynamic(() => import("@/components/dashboard/email-chart"), {
17
+ loading: () => <Skeleton className="h-[300px] w-full" />,
18
  ssr: false
19
  });
 
 
 
 
20
 
21
  interface DashboardStats {
22
  totalBusinesses: number;
 
 
23
  emailsSent: number;
 
 
24
  openRate: number;
 
25
  quotaUsed: number;
26
  quotaLimit: number;
27
  }
 
32
  opened: number;
33
  }
34
 
 
 
 
 
 
 
 
35
  export default function DashboardPage() {
36
  const [businesses, setBusinesses] = useState<Business[]>([]);
 
 
 
 
 
 
37
  const [stats, setStats] = useState<DashboardStats | null>(null);
38
  const [chartData, setChartData] = useState<ChartDataPoint[]>([]);
 
 
 
 
 
 
 
 
39
 
40
  // API Hooks
41
+ const { get: getBusinessesApi, loading: loadingBusinesses } = useApi<{ businesses: Business[] }>();
 
 
42
  const { get: getStatsApi, loading: loadingStats } = useApi<{ stats: DashboardStats; chartData: ChartDataPoint[] }>();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
  useEffect(() => {
45
+ const fetchData = async () => {
46
+ const [businessData, statsData] = await Promise.all([
47
+ getBusinessesApi("/api/businesses"),
48
+ getStatsApi("/api/dashboard/stats")
49
+ ]);
50
+
51
+ if (businessData?.businesses) setBusinesses(businessData.businesses);
52
+ if (statsData) {
53
+ setStats(statsData.stats);
54
+ setChartData(statsData.chartData || []);
55
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  };
57
+ fetchData();
58
+ }, [getBusinessesApi, getStatsApi]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
 
60
  return (
61
  <div className="space-y-6">
62
+ <div className="flex items-center justify-between">
63
+ <div>
64
+ <h1 className="text-3xl font-bold tracking-tight">Overview</h1>
65
+ <p className="text-muted-foreground">
66
+ Your agency performance at a glance.
67
+ </p>
68
+ </div>
69
+ <div className="flex gap-2">
70
+ <Button asChild>
71
+ <Link href="/dashboard/scraper">
72
+ Start New Search <ArrowRight className="ml-2 h-4 w-4" />
73
+ </Link>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  </Button>
75
+ </div>
76
+ </div>
77
 
78
+ {/* Primary Stats */}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
80
  {loadingStats || !stats ? (
81
  Array.from({ length: 4 }).map((_, i) => (
82
+ <Skeleton key={i} className="h-32 w-full rounded-xl" />
 
 
 
 
 
 
 
 
 
83
  ))
84
  ) : (
85
+ <>
86
+ <AnimatedContainer delay={0.1}>
87
+ <StatCard title="Total Leads" value={stats.totalBusinesses} icon={Users} />
88
+ </AnimatedContainer>
89
  <AnimatedContainer delay={0.2}>
90
+ <StatCard title="Emails Sent" value={stats.emailsSent} icon={Mail} />
 
 
 
 
91
  </AnimatedContainer>
92
  <AnimatedContainer delay={0.3}>
 
 
 
 
 
 
 
93
  <Card>
94
  <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
95
  <CardTitle className="text-sm font-medium">Daily Quota</CardTitle>
 
97
  </CardHeader>
98
  <CardContent>
99
  <div className="text-2xl font-bold">{stats.quotaUsed} / {stats.quotaLimit}</div>
 
 
 
100
  <div className="mt-3 h-2 w-full bg-secondary rounded-full overflow-hidden">
101
  <div
102
  className={`h-full ${stats.quotaUsed >= stats.quotaLimit ? 'bg-red-500' : 'bg-blue-500'}`}
 
106
  </CardContent>
107
  </Card>
108
  </AnimatedContainer>
109
+ <AnimatedContainer delay={0.4}>
110
+ <StatCard title="Open Rate" value={`${stats.openRate}%`} icon={TrendingUp} />
111
+ </AnimatedContainer>
112
+ </>
113
+ )}
114
+ </div>
115
+
116
+ <div className="grid gap-6 md:grid-cols-7">
117
+ {/* Main Chart */}
118
+ <Card className="col-span-4">
119
+ <CardHeader>
120
+ <CardTitle>Email Performance</CardTitle>
121
+ <CardDescription>Sent vs Opened emails over the last 7 days</CardDescription>
122
+ </CardHeader>
123
+ <CardContent className="pl-2">
 
 
 
124
  <EmailChart data={chartData} loading={loadingStats} />
125
+ </CardContent>
126
+ </Card>
127
+
128
+ {/* Recent Activity / Demographics Placeholder */}
129
+ <Card className="col-span-3">
130
+ <CardHeader>
131
+ <CardTitle>Lead Demographics</CardTitle>
132
+ <CardDescription>Business types distribution</CardDescription>
133
+ </CardHeader>
134
+ <CardContent>
135
+ <div className="h-[300px] flex items-center justify-center text-muted-foreground text-sm border-2 border-dashed rounded-lg">
136
+ Coming Soon: Business Type Chart
137
+ </div>
138
+ </CardContent>
139
+ </Card>
140
+ </div>
141
 
142
+ {/* Recent Businesses */}
143
+ <Card>
144
  <CardHeader className="flex flex-row items-center justify-between">
145
+ <CardTitle>Recent Leads</CardTitle>
146
+ <Button variant="ghost" size="sm" asChild>
147
+ <Link href="/dashboard/businesses">View All</Link>
148
  </Button>
149
+ </CardHeader>
150
+ <CardContent>
151
  <BusinessTable
152
+ businesses={businesses.slice(0, 5)}
153
+ onViewDetails={() => { }}
154
+ onSendEmail={() => { }}
155
+ isLoading={loadingBusinesses}
156
  />
157
+ </CardContent>
158
+ </Card>
159
+ </div>
160
+ );
 
 
 
 
 
 
 
 
161
  }
app/dashboard/scraper/page.tsx ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Input } from "@/components/ui/input";
7
+ import { Label } from "@/components/ui/label";
8
+ import { Checkbox } from "@/components/ui/checkbox";
9
+ import { Loader2, Play, Sparkles } from "lucide-react";
10
+ import { BusinessTypeSelect } from "@/components/business-type-select";
11
+ import { KeywordInput } from "@/components/keyword-input";
12
+ import { ActiveTaskCard } from "@/components/active-task-card";
13
+ import { useApi } from "@/hooks/use-api";
14
+ import { toast } from "sonner";
15
+ import { allLocations } from "@/lib/locations";
16
+ import { sendNotification } from "@/components/notification-bell";
17
+
18
+ interface ScraperTask {
19
+ id: string;
20
+ type?: string;
21
+ status: string;
22
+ workflowName?: string;
23
+ businessesFound?: number;
24
+ }
25
+
26
+ export default function ScraperPage() {
27
+ const [businessType, setBusinessType] = useState<string>("");
28
+ const [location, setLocation] = useState<string>("");
29
+ const [keywords, setKeywords] = useState<string[]>([]);
30
+ const [scrapingSources, setScrapingSources] = useState<string[]>(["google-maps", "google-search"]);
31
+ const [isScrapingStarted, setIsScrapingStarted] = useState(false);
32
+ const [isGeneratingKeywords, setIsGeneratingKeywords] = useState(false);
33
+ const [activeTask, setActiveTask] = useState<ScraperTask | null>(null);
34
+
35
+ const { post: startScraping } = useApi();
36
+ const { post: generateKeywords } = useApi<{ keywords: string[] }>();
37
+ const { get: getActiveTasks } = useApi<{ tasks: ScraperTask[] }>();
38
+
39
+ const handleStartScraping = async () => {
40
+ if (!businessType || !location) return;
41
+
42
+ setIsScrapingStarted(true);
43
+ try {
44
+ const result = await startScraping("/api/scraping/start", {
45
+ targetBusinessType: businessType,
46
+ keywords,
47
+ location,
48
+ sources: scrapingSources,
49
+ });
50
+
51
+ if (result) {
52
+ sendNotification({
53
+ title: "Scraping job started!",
54
+ message: "Check the Tasks page to monitor progress.",
55
+ type: "success",
56
+ link: "/dashboard/tasks",
57
+ actionLabel: "View Tasks"
58
+ });
59
+
60
+ // Fetch active task immediately to show updated status
61
+ const tasksData = await getActiveTasks("/api/tasks");
62
+ if (tasksData?.tasks) {
63
+ const activeJob = tasksData.tasks.find(t => t.type === 'scraping' && (t.status === 'processing' || t.status === 'paused'));
64
+ if (activeJob) {
65
+ setActiveTask(activeJob);
66
+ }
67
+ }
68
+ }
69
+ } catch (error) {
70
+ console.error("Scraping error:", error);
71
+ toast.error("Failed to start scraping");
72
+ } finally {
73
+ setIsScrapingStarted(false);
74
+ }
75
+ };
76
+
77
+ const handleGenerateKeywords = async () => {
78
+ if (!businessType) {
79
+ toast.error("Please select a business type first");
80
+ return;
81
+ }
82
+
83
+ setIsGeneratingKeywords(true);
84
+ try {
85
+ const result = await generateKeywords("/api/keywords/generate", {
86
+ businessType,
87
+ });
88
+
89
+ if (result?.keywords) {
90
+ const newKeywords = result.keywords.filter(
91
+ (kw: string) => !keywords.includes(kw)
92
+ );
93
+ setKeywords([...keywords, ...newKeywords]);
94
+ sendNotification({
95
+ title: "Keywords Generated",
96
+ message: `Successfully generated ${newKeywords.length} new keywords.`,
97
+ type: "success"
98
+ });
99
+ }
100
+ } catch (error) {
101
+ console.error("Keyword generation error:", error);
102
+ toast.error("Failed to generate keywords");
103
+ } finally {
104
+ setIsGeneratingKeywords(false);
105
+ }
106
+ };
107
+
108
+ return (
109
+ <div className="space-y-6 max-w-4xl mx-auto p-4">
110
+ <div>
111
+ <h1 className="text-3xl font-bold tracking-tight">Lead Scraper</h1>
112
+ <p className="text-muted-foreground">
113
+ Find local businesses and build your lead database.
114
+ </p>
115
+ </div>
116
+
117
+ <Card>
118
+ <CardHeader>
119
+ <CardTitle>Start New Search</CardTitle>
120
+ <CardDescription>
121
+ Configure your scraping parameters below.
122
+ </CardDescription>
123
+ </CardHeader>
124
+ <CardContent className="space-y-4">
125
+ <div className="grid gap-4 md:grid-cols-2">
126
+ <div className="space-y-2">
127
+ <Label htmlFor="businessType">Business Type</Label>
128
+ <BusinessTypeSelect
129
+ value={businessType}
130
+ onValueChange={setBusinessType}
131
+ />
132
+ </div>
133
+ <div className="space-y-2">
134
+ <Label htmlFor="location">Location</Label>
135
+ <Input
136
+ id="location"
137
+ list="locations"
138
+ placeholder="e.g., New York, NY"
139
+ value={location}
140
+ onChange={(e) => setLocation(e.target.value)}
141
+ />
142
+ <datalist id="locations">
143
+ {allLocations.map((loc) => (
144
+ <option key={loc} value={loc} />
145
+ ))}
146
+ </datalist>
147
+ </div>
148
+ </div>
149
+ <div className="space-y-2">
150
+ <div className="flex items-center justify-between">
151
+ <Label htmlFor="keywords">Keywords (Optional)</Label>
152
+ <Button
153
+ type="button"
154
+ variant="outline"
155
+ size="icon"
156
+ onClick={handleGenerateKeywords}
157
+ disabled={!businessType || isGeneratingKeywords}
158
+ className="gap-2"
159
+ >
160
+ {isGeneratingKeywords ? (
161
+ <Loader2 className="h-4 w-4 animate-spin" />
162
+ ) : (
163
+ <Sparkles className="h-4 w-4" />
164
+ )}
165
+ </Button>
166
+ </div>
167
+ <KeywordInput
168
+ businessTypeId={businessType}
169
+ value={keywords}
170
+ onChange={setKeywords}
171
+ placeholder="Add relevant keywords..."
172
+ />
173
+ </div>
174
+
175
+ <div className="space-y-2">
176
+ <Label>Scraping Sources</Label>
177
+ <div className="flex flex-wrap gap-3">
178
+ {["google-maps", "google-search", "linkedin", "facebook", "instagram"].map((source) => (
179
+ <label key={source} className="flex items-center gap-2 cursor-pointer border p-2 rounded hover:bg-muted">
180
+ <Checkbox
181
+ checked={scrapingSources.includes(source)}
182
+ onCheckedChange={(checked) => {
183
+ if (checked) setScrapingSources([...scrapingSources, source]);
184
+ else setScrapingSources(scrapingSources.filter(s => s !== source));
185
+ }}
186
+ />
187
+ <span className="text-sm capitalize">{source.replace('-', ' ')}</span>
188
+ </label>
189
+ ))}
190
+ </div>
191
+ </div>
192
+ <Button
193
+ onClick={handleStartScraping}
194
+ disabled={isScrapingStarted || !businessType || !location}
195
+ className="w-full cursor-pointer"
196
+ size="lg"
197
+ >
198
+ {isScrapingStarted ? (
199
+ <>
200
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
201
+ Scraping...
202
+ </>
203
+ ) : (
204
+ <>
205
+ <Play className="mr-2 h-4 w-4" />
206
+ Start Scraping
207
+ </>
208
+ )}
209
+ </Button>
210
+
211
+ {activeTask && (
212
+ <div className="mt-4">
213
+ <ActiveTaskCard
214
+ jobId={activeTask.id}
215
+ workflowName={activeTask.workflowName || ""}
216
+ status={activeTask.status}
217
+ businessesFound={activeTask.businessesFound || 0}
218
+ onDismiss={() => setActiveTask(null)}
219
+ />
220
+ </div>
221
+ )}
222
+ </CardContent>
223
+ </Card>
224
+ </div>
225
+ );
226
+ }
app/dashboard/settings/page.tsx CHANGED
@@ -8,7 +8,7 @@ import { Label } from "@/components/ui/label";
8
  import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
9
  import { Switch } from "@/components/ui/switch";
10
  import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
11
- import { User, Key, Bell, Copy, Check, Loader2 } from "lucide-react";
12
  import { Badge } from "@/components/ui/badge";
13
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
14
  import { Plus } from "lucide-react";
@@ -16,7 +16,7 @@ import { useToast } from "@/hooks/use-toast";
16
  import { useTheme } from "@/components/theme-provider";
17
  import { signOut } from "next-auth/react";
18
  import { useApi } from "@/hooks/use-api";
19
- import { UserProfile } from "@/types";
20
 
21
  import {
22
  Dialog,
@@ -28,6 +28,7 @@ import {
28
  } from "@/components/ui/dialog";
29
  import { Moon, Sun, LogOut, Trash2, AlertTriangle, Palette } from "lucide-react";
30
  import { MailSettings } from "@/components/mail/mail-settings";
 
31
 
32
  interface StatusResponse {
33
  database: boolean;
@@ -43,7 +44,7 @@ export default function SettingsPage() {
43
  const [isSavingNotifications, setIsSavingNotifications] = useState(false);
44
 
45
  // API Hooks
46
- const { get: getSettings, patch: patchSettings, loading: settingsLoading } = useApi<{ user: UserProfile & { isGeminiKeySet: boolean, isGmailConnected: boolean, isLinkedinCookieSet: boolean } }>();
47
  const { get: getStatus, loading: statusLoading } = useApi<StatusResponse>();
48
  const { del: deleteUserFn, loading: deletingUser } = useApi<void>();
49
  const { del: deleteDataFn, loading: deletingData } = useApi<void>();
@@ -54,6 +55,7 @@ export default function SettingsPage() {
54
  const [geminiApiKey, setGeminiApiKey] = useState("");
55
  const [isGeminiKeySet, setIsGeminiKeySet] = useState(false);
56
  const [isGmailConnected, setIsGmailConnected] = useState(false);
 
57
 
58
  // Connection Status State
59
  const [connectionStatus, setConnectionStatus] = useState({ database: false, redis: false });
@@ -94,7 +96,11 @@ export default function SettingsPage() {
94
  }
95
  setIsGeminiKeySet(settingsData.user.isGeminiKeySet);
96
  setIsGmailConnected(settingsData.user.isGmailConnected);
 
 
 
97
  }
 
98
 
99
  // Fetch Connection Status
100
  const statusData = await getStatus("/api/settings/status");
@@ -550,8 +556,17 @@ export default function SettingsPage() {
550
  </CardDescription>
551
  </CardHeader>
552
  <CardContent className="space-y-6">
 
 
 
 
 
 
 
 
 
553
  {/* Mail Settings (Gmail) */}
554
- <div className="space-y-2">
555
  <MailSettings isConnected={isGmailConnected} email={session?.user?.email} />
556
  </div>
557
 
@@ -592,7 +607,17 @@ export default function SettingsPage() {
592
  {/* Gemini API */}
593
  <div className="space-y-2">
594
  <div className="flex items-center justify-between">
595
- <Label>Gemini API Key</Label>
 
 
 
 
 
 
 
 
 
 
596
  <Badge variant={isGeminiKeySet ? "default" : "destructive"}>
597
  {isGeminiKeySet ? "Configured" : "Not Set"}
598
  </Badge>
@@ -622,10 +647,21 @@ export default function SettingsPage() {
622
  </p>
623
  </div>
624
 
625
- {/* LinkedIn Session Cookie */}
626
  <div className="space-y-2">
627
  <div className="flex items-center justify-between">
628
- <Label>LinkedIn Session Cookie (li_at)</Label>
 
 
 
 
 
 
 
 
 
 
 
629
  <Badge variant={isLinkedinCookieSet ? "default" : "secondary"}>
630
  {isLinkedinCookieSet ? "Configured" : "Not Set"}
631
  </Badge>
@@ -651,10 +687,8 @@ export default function SettingsPage() {
651
  </Button>
652
  </div>
653
  <p className="text-xs text-muted-foreground">
654
- Required for real LinkedIn automation.
655
- <span className="text-yellow-600 dark:text-yellow-400 font-medium ml-1">
656
- Warning: Use with caution.
657
- </span>
658
  </p>
659
  </div>
660
 
 
8
  import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
9
  import { Switch } from "@/components/ui/switch";
10
  import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
11
+ import { User, Key, Bell, Copy, Check, Loader2, ExternalLink } from "lucide-react";
12
  import { Badge } from "@/components/ui/badge";
13
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
14
  import { Plus } from "lucide-react";
 
16
  import { useTheme } from "@/components/theme-provider";
17
  import { signOut } from "next-auth/react";
18
  import { useApi } from "@/hooks/use-api";
19
+ import { ConnectedAccount, UserProfile } from "@/types";
20
 
21
  import {
22
  Dialog,
 
28
  } from "@/components/ui/dialog";
29
  import { Moon, Sun, LogOut, Trash2, AlertTriangle, Palette } from "lucide-react";
30
  import { MailSettings } from "@/components/mail/mail-settings";
31
+ import { SocialSettings } from "@/components/settings/social-settings";
32
 
33
  interface StatusResponse {
34
  database: boolean;
 
44
  const [isSavingNotifications, setIsSavingNotifications] = useState(false);
45
 
46
  // API Hooks
47
+ const { get: getSettings, patch: patchSettings, loading: settingsLoading } = useApi<{ user: UserProfile & { isGeminiKeySet: boolean, isGmailConnected: boolean, isLinkedinCookieSet: boolean }, connectedAccounts: ConnectedAccount[] }>();
48
  const { get: getStatus, loading: statusLoading } = useApi<StatusResponse>();
49
  const { del: deleteUserFn, loading: deletingUser } = useApi<void>();
50
  const { del: deleteDataFn, loading: deletingData } = useApi<void>();
 
55
  const [geminiApiKey, setGeminiApiKey] = useState("");
56
  const [isGeminiKeySet, setIsGeminiKeySet] = useState(false);
57
  const [isGmailConnected, setIsGmailConnected] = useState(false);
58
+ const [connectedAccounts, setConnectedAccounts] = useState<ConnectedAccount[]>([]);
59
 
60
  // Connection Status State
61
  const [connectionStatus, setConnectionStatus] = useState({ database: false, redis: false });
 
96
  }
97
  setIsGeminiKeySet(settingsData.user.isGeminiKeySet);
98
  setIsGmailConnected(settingsData.user.isGmailConnected);
99
+
100
+ if (settingsData.connectedAccounts) {
101
+ setConnectedAccounts(settingsData.connectedAccounts);
102
  }
103
+ }
104
 
105
  // Fetch Connection Status
106
  const statusData = await getStatus("/api/settings/status");
 
556
  </CardDescription>
557
  </CardHeader>
558
  <CardContent className="space-y-6">
559
+ {/* Social Integrations */}
560
+ <div className="space-y-4 pt-4 border-t">
561
+ <div>
562
+ <h3 className="text-lg font-medium">Social Connections</h3>
563
+ <p className="text-sm text-muted-foreground">Manage your social media accounts for auto-posting.</p>
564
+ </div>
565
+ <SocialSettings connectedAccounts={connectedAccounts} />
566
+ </div>
567
+
568
  {/* Mail Settings (Gmail) */}
569
+ <div className="space-y-2 pt-4 border-t">
570
  <MailSettings isConnected={isGmailConnected} email={session?.user?.email} />
571
  </div>
572
 
 
607
  {/* Gemini API */}
608
  <div className="space-y-2">
609
  <div className="flex items-center justify-between">
610
+ <div className="flex items-center gap-2">
611
+ <Label>Gemini API Key</Label>
612
+ <a
613
+ href="https://aistudio.google.com/app/apikey"
614
+ target="_blank"
615
+ rel="noopener noreferrer"
616
+ className="text-xs text-primary hover:underline flex items-center gap-1"
617
+ >
618
+ (Get Key <ExternalLink className="h-3 w-3" />)
619
+ </a>
620
+ </div>
621
  <Badge variant={isGeminiKeySet ? "default" : "destructive"}>
622
  {isGeminiKeySet ? "Configured" : "Not Set"}
623
  </Badge>
 
647
  </p>
648
  </div>
649
 
650
+ {/* LinkedIn Session Cookie (Legacy/Alternative) */}
651
  <div className="space-y-2">
652
  <div className="flex items-center justify-between">
653
+ <div className="flex items-center gap-2">
654
+ <Label>LinkedIn Session Cookie (Scraping)</Label>
655
+ <a
656
+ href="https://www.linkedin.com/"
657
+ target="_blank"
658
+ rel="noopener noreferrer"
659
+ className="text-xs text-primary hover:underline flex items-center gap-1"
660
+ title="Login and find 'li_at' cookie in Developer Tools > Application"
661
+ >
662
+ (Open LinkedIn <ExternalLink className="h-3 w-3" />)
663
+ </a>
664
+ </div>
665
  <Badge variant={isLinkedinCookieSet ? "default" : "secondary"}>
666
  {isLinkedinCookieSet ? "Configured" : "Not Set"}
667
  </Badge>
 
687
  </Button>
688
  </div>
689
  <p className="text-xs text-muted-foreground">
690
+ <strong>Required ONLY for the Scraper Tool.</strong> This is separate from the &quot;Social Connections&quot; above.
691
+ Used for extracting data from LinkedIn profiles directly. Use F12 &gt; Application &gt; Cookies to find <code>li_at</code>.
 
 
692
  </p>
693
  </div>
694
 
app/dashboard/social/automations/page.tsx CHANGED
@@ -5,7 +5,7 @@ import { eq } from "drizzle-orm";
5
  import { redirect } from "next/navigation";
6
  import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
7
  import { Button } from "@/components/ui/button";
8
- import { Plus, Zap } from "lucide-react";
9
  import Link from "next/link";
10
  import { Badge } from "@/components/ui/badge";
11
 
@@ -25,6 +25,12 @@ export default async function AutomationsPage() {
25
  <div className="flex flex-col gap-6 p-6">
26
  <div className="flex items-center justify-between">
27
  <div className="flex flex-col gap-2">
 
 
 
 
 
 
28
  <h1 className="text-3xl font-bold tracking-tight">Social Automations</h1>
29
  <p className="text-muted-foreground">
30
  Automatically reply to comments and DMs based on keywords.
 
5
  import { redirect } from "next/navigation";
6
  import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
7
  import { Button } from "@/components/ui/button";
8
+ import { Plus, Zap, ChevronLeft } from "lucide-react";
9
  import Link from "next/link";
10
  import { Badge } from "@/components/ui/badge";
11
 
 
25
  <div className="flex flex-col gap-6 p-6">
26
  <div className="flex items-center justify-between">
27
  <div className="flex flex-col gap-2">
28
+ <Button variant="ghost" className="w-fit pl-0 hover:bg-transparent hover:text-primary" asChild>
29
+ <Link href="/dashboard/social">
30
+ <ChevronLeft className="mr-2 h-4 w-4" />
31
+ Back to Dashboard
32
+ </Link>
33
+ </Button>
34
  <h1 className="text-3xl font-bold tracking-tight">Social Automations</h1>
35
  <p className="text-muted-foreground">
36
  Automatically reply to comments and DMs based on keywords.
app/dashboard/social/page.tsx CHANGED
@@ -1,145 +1,265 @@
1
- import { auth } from "@/auth";
2
- import { db } from "@/db";
3
- import { connectedAccounts } from "@/db/schema";
4
- import { eq } from "drizzle-orm";
5
- import { redirect } from "next/navigation";
6
  import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
7
  import { Button } from "@/components/ui/button";
8
- import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
9
- import { Facebook, Share2, Plus, AlertCircle, CheckCircle2 } from "lucide-react";
 
10
  import Link from "next/link";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
- export default async function SocialDashboardPage({
13
- searchParams,
14
- }: {
15
- searchParams: Promise<{ [key: string]: string | string[] | undefined }>
16
- }) {
17
- const session = await auth();
18
- if (!session?.user?.id) redirect("/auth/signin");
19
 
20
- const resolvedSearchParams = await searchParams;
21
- const error = resolvedSearchParams?.error as string;
22
- const success = resolvedSearchParams?.success as string;
 
 
 
 
 
 
 
 
23
 
24
- const accounts = await db.query.connectedAccounts.findMany({
25
- where: eq(connectedAccounts.userId, session.user.id),
26
- });
 
 
 
 
 
 
 
 
27
 
28
- const fbAccount = accounts.find((a) => a.provider === "facebook");
29
- // const igAccount = accounts.find((a) => a.provider === "instagram"); // In this model, they might be same "Facebook" login entry, or separate. For now let's assume we store the Facebook User Token.
30
 
31
- // Note: We currently store "facebook" provider for the main connection.
32
- // Instagram accounts are fetched VIA the Facebook connection.
33
- // So we primarily check for the Facebook provider existence.
 
 
 
 
 
 
 
 
 
 
34
 
35
  return (
36
  <div className="flex flex-col gap-6 p-6">
37
  <div className="flex flex-col gap-2">
38
- <h1 className="text-3xl font-bold tracking-tight">Social Suite</h1>
39
- <p className="text-muted-foreground">
40
- Manage your connected social accounts for auto-posting and engagement.
41
- </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  </div>
43
 
44
- {error && (
45
- <Alert variant="destructive">
46
- <AlertCircle className="h-4 w-4" />
47
- <AlertTitle>Error</AlertTitle>
48
- <AlertDescription>
49
- {error === "access_denied" ? "You denied access. Please try again and accept permissions." : error}
50
- </AlertDescription>
51
- </Alert>
52
- )}
53
-
54
- {success && (
55
- <Alert className="border-green-500 text-green-600">
56
- <CheckCircle2 className="h-4 w-4" />
57
- <AlertTitle>Success</AlertTitle>
58
- <AlertDescription>Account connected successfully!</AlertDescription>
59
- </Alert>
60
- )}
61
-
62
- <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
63
- {/* Facebook Connection Card */}
64
- <Card>
65
- <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
66
- <CardTitle className="text-xl font-medium">Facebook & Instagram</CardTitle>
67
- <Facebook className="h-6 w-6 text-blue-600" />
68
- </CardHeader>
69
- <CardContent>
70
- {fbAccount ? (
71
- <div className="flex flex-col gap-4 mt-4">
72
- <div className="flex items-center gap-3">
73
- {fbAccount.picture ? (
74
- // eslint-disable-next-line @next/next/no-img-element
75
- <img src={fbAccount.picture} alt={fbAccount.name || "Profile"} className="w-10 h-10 rounded-full" />
76
- ) : (
77
- <div className="w-10 h-10 rounded-full bg-slate-200 flex items-center justify-center">
78
- <span className="text-lg font-bold text-slate-500">{fbAccount.name?.charAt(0)}</span>
79
- </div>
80
- )}
81
- <div>
82
- <p className="font-medium">{fbAccount.name}</p>
83
- <p className="text-xs text-muted-foreground">Connected as {fbAccount.providerAccountId}</p>
84
- </div>
85
- </div>
86
- <div className="flex items-center gap-2 text-sm text-green-600 bg-green-50 p-2 rounded border border-green-100">
87
- <CheckCircle2 className="h-4 w-4" />
88
- Long-Lived Token Active
89
  </div>
90
- <Button variant="outline" className="w-full" asChild>
91
- <Link href="/api/social/connect/facebook">Reconnect / Refresh</Link>
92
- </Button>
93
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
94
  ) : (
95
- <div className="flex flex-col gap-4 mt-4">
96
- <p className="text-sm text-muted-foreground">
97
- Connect your Facebook account to manage Pages and linked Instagram Business accounts.
98
- </p>
99
- <Button className="w-full bg-[#1877F2] hover:bg-[#1864D9]" asChild>
100
- <Link href="/api/social/connect/facebook">
101
- <Plus className="h-4 w-4 mr-2" /> Connect Facebook
102
- </Link>
103
- </Button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  </div>
105
  )}
106
- </CardContent>
107
- </Card>
108
-
109
- {/* Placeholder for future specific Instagram if needed separately (usually handled via FB Graph) */}
110
- <Card className="opacity-75 relative overflow-hidden">
111
- <div className="absolute inset-0 bg-slate-50/50 z-10 flex items-center justify-center backdrop-blur-[1px]">
112
- <span className="bg-slate-900 text-white text-xs px-2 py-1 rounded">Coming Soon</span>
113
  </div>
114
- <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
115
- <CardTitle className="text-xl font-medium">LinkedIn (Social)</CardTitle>
116
- <Share2 className="h-6 w-6 text-blue-500" />
117
- </CardHeader>
118
- <CardContent>
119
- <div className="flex flex-col gap-4 mt-4">
120
- <p className="text-sm text-muted-foreground">Manage Company Page posts and analytics.</p>
121
- <Button disabled variant="outline" className="w-full">Connect LinkedIn</Button>
122
- </div>
123
- </CardContent>
124
- </Card>
125
- </div>
126
 
127
- {/* Future: Post Capability Preview */}
128
- {fbAccount && (
129
- <div className="mt-8 border-t pt-8">
130
- <h2 className="text-lg font-semibold mb-4">Quick Actions</h2>
131
- <div className="flex gap-4">
132
- <Button asChild>
133
- <Link href="/dashboard/social/posts/new">Create New Post</Link>
134
- </Button>
135
- <Button variant="secondary" asChild>
136
- <Link href="/dashboard/social/automations">Auto-Reply Rules</Link>
137
- </Button>
138
- <Button disabled variant="secondary">View Scheduled Posts</Button>
139
- </div>
140
- </div>
141
- )}
 
 
 
 
 
 
 
 
 
 
 
 
 
142
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  </div>
144
  );
145
  }
 
1
+ "use client";
2
+
 
 
 
3
  import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
4
  import { Button } from "@/components/ui/button";
5
+ import { PenTool, MessageSquare, Settings, ExternalLink, Users, Eye, Trash2 } from "lucide-react";
6
+ import { SiFacebook, SiYoutube } from "@icons-pack/react-simple-icons";
7
+ import { Linkedin } from "lucide-react";
8
  import Link from "next/link";
9
+ import { useApi } from "@/hooks/use-api";
10
+ import { useEffect, useState } from "react";
11
+ import { Badge } from "@/components/ui/badge";
12
+ import { toast } from "sonner";
13
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
14
+ import Image from "next/image";
15
+
16
+ interface SocialPost {
17
+ id: string;
18
+ content: string;
19
+ mediaUrls: string[];
20
+ platform: string;
21
+ status: string;
22
+ createdAt: string;
23
+ platformPostId?: string;
24
+ }
25
+
26
+ interface AnalyticsPlatform {
27
+ platform: string;
28
+ name?: string;
29
+ followers: number;
30
+ reach: number;
31
+ }
32
+
33
+ interface AnalyticsData {
34
+ totalFollowers: number;
35
+ totalReach: number;
36
+ platforms: AnalyticsPlatform[];
37
+ }
38
 
39
+ export default function SocialDashboardPage() {
40
+ const { get, del } = useApi();
41
+ const [posts, setPosts] = useState<SocialPost[]>([]);
42
+ const [analytics, setAnalytics] = useState<AnalyticsData | null>(null);
43
+ const [analyticsLoading, setAnalyticsLoading] = useState(true);
 
 
44
 
45
+ useEffect(() => {
46
+ const fetchData = async () => {
47
+ // Fetch Posts
48
+ try {
49
+ const postsData = await get("/api/social/posts") as { posts: SocialPost[] };
50
+ if (postsData?.posts) {
51
+ setPosts(postsData.posts);
52
+ }
53
+ } catch (error) {
54
+ console.error("Failed to fetch posts:", error);
55
+ }
56
 
57
+ // Fetch Analytics
58
+ setAnalyticsLoading(true);
59
+ try {
60
+ const analyticsData = await get("/api/social/analytics") as AnalyticsData;
61
+ setAnalytics(analyticsData);
62
+ } catch (error) {
63
+ console.error("Failed to fetch analytics:", error);
64
+ } finally {
65
+ setAnalyticsLoading(false);
66
+ }
67
+ };
68
 
69
+ fetchData();
70
+ }, [get]);
71
 
72
+ const handleDelete = async (id: string, e: React.MouseEvent) => {
73
+ e.preventDefault(); // Prevent navigating if wrapped
74
+ if (!confirm("Are you sure you want to delete this post history?")) return;
75
+
76
+ try {
77
+ await del(`/api/social/posts?id=${id}`);
78
+ setPosts(posts.filter(p => p.id !== id));
79
+ toast.success("Post deleted");
80
+ } catch (error) {
81
+ console.error("Delete failed", error);
82
+ toast.error("Failed to delete post");
83
+ }
84
+ };
85
 
86
  return (
87
  <div className="flex flex-col gap-6 p-6">
88
  <div className="flex flex-col gap-2">
89
+ <div className="flex items-center justify-between">
90
+ <div>
91
+ <h1 className="text-3xl font-bold tracking-tight">Social Content</h1>
92
+ <p className="text-muted-foreground">
93
+ Create posts, manage automations, and view analytics.
94
+ </p>
95
+ </div>
96
+ <div className="flex gap-2">
97
+ <Button variant="outline" asChild>
98
+ <Link href="/dashboard/settings?tab=api">
99
+ <Settings className="mr-2 h-4 w-4" />
100
+ Manage Connections
101
+ </Link>
102
+ </Button>
103
+ <Button asChild>
104
+ <Link href="/dashboard/social/posts/new">
105
+ <PenTool className="mr-2 h-4 w-4" /> Create Post
106
+ </Link>
107
+ </Button>
108
+ </div>
109
+ </div>
110
  </div>
111
 
112
+ <Tabs defaultValue="content" className="space-y-4">
113
+ <TabsList>
114
+ <TabsTrigger value="content">Content</TabsTrigger>
115
+ <TabsTrigger value="analytics">Analytics</TabsTrigger>
116
+ </TabsList>
117
+
118
+ <TabsContent value="content" className="space-y-6">
119
+ <div className="grid gap-6 md:grid-cols-3">
120
+ {/* Stats / Quick Actions */}
121
+ <Card className="hover:bg-muted/50 transition-colors">
122
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
123
+ <CardTitle className="text-xl font-medium">Automations</CardTitle>
124
+ <MessageSquare className="h-6 w-6 text-primary" />
125
+ </CardHeader>
126
+ <CardContent>
127
+ <div className="flex flex-col gap-4 mt-4">
128
+ <p className="text-sm text-muted-foreground">
129
+ Configure auto-replies and engagement rules.
130
+ </p>
131
+ <Button variant="secondary" asChild>
132
+ <Link href="/dashboard/social/automations">Manage Rules</Link>
133
+ </Button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  </div>
135
+ </CardContent>
136
+ </Card>
137
+ </div>
138
+
139
+ {/* Recent Posts Table */}
140
+ <div className="space-y-4">
141
+ <h2 className="text-xl font-semibold">Recent Posts</h2>
142
+ {posts.length === 0 ? (
143
+ <Card>
144
+ <CardContent className="flex flex-col items-center justify-center p-10 text-muted-foreground">
145
+ <p>No posts yet. create your first post!</p>
146
+ <Button variant="link" asChild>
147
+ <Link href="/dashboard/social/posts/new">Create Post</Link>
148
+ </Button>
149
+ </CardContent>
150
+ </Card>
151
  ) : (
152
+ <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
153
+ {posts.map((post) => (
154
+ <Card key={post.id} className="overflow-hidden">
155
+ {post.mediaUrls && post.mediaUrls.length > 0 && (
156
+ <div className="aspect-video bg-muted relative">
157
+ <Image
158
+ src={post.mediaUrls[0]}
159
+ alt="Post media"
160
+ fill
161
+ className="object-cover"
162
+ unoptimized
163
+ />
164
+ </div>
165
+ )}
166
+ <CardContent className="p-4 space-y-3">
167
+ <div className="flex items-center justify-between">
168
+ <Badge variant={post.status === 'published' ? 'default' : post.status === 'failed' ? 'destructive' : 'secondary'}>
169
+ {post.status}
170
+ </Badge>
171
+ <div className="flex gap-1 text-muted-foreground">
172
+ {post.platform === 'facebook' && <SiFacebook className="h-4 w-4 fill-current" />}
173
+ {post.platform === 'linkedin' && <Linkedin className="h-4 w-4 text-[#0077b5] fill-current" />}
174
+ {post.platform === 'youtube' && <SiYoutube className="h-4 w-4 fill-current" />}
175
+ </div>
176
+ </div>
177
+ <p className="text-sm line-clamp-3">{post.content || "No caption"}</p>
178
+ <div className="flex items-center justify-between text-xs text-muted-foreground pt-2 border-t">
179
+ <span>{new Date(post.createdAt).toLocaleDateString()}</span>
180
+ <div className="flex gap-1">
181
+ {post.platformPostId && (
182
+ <Button variant="ghost" size="icon" className="h-6 w-6" title="View on Platform">
183
+ <ExternalLink className="h-3 w-3" />
184
+ </Button>
185
+ )}
186
+ <Button
187
+ variant="ghost"
188
+ size="icon"
189
+ className="h-6 w-6 text-destructive hover:text-destructive/90 hover:bg-destructive/10"
190
+ onClick={(e) => handleDelete(post.id, e)}
191
+ title="Delete Post History"
192
+ >
193
+ <Trash2 className="h-3 w-3" />
194
+ </Button>
195
+ </div>
196
+ </div>
197
+ </CardContent>
198
+ </Card>
199
+ ))}
200
  </div>
201
  )}
 
 
 
 
 
 
 
202
  </div>
203
+ </TabsContent>
 
 
 
 
 
 
 
 
 
 
 
204
 
205
+ <TabsContent value="analytics" className="space-y-6">
206
+ {analyticsLoading ? (
207
+ <div className="flex items-center justify-center p-12">Checking Analytics...</div>
208
+ ) : (
209
+ <>
210
+ {/* Overview Cards */}
211
+ <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
212
+ <Card>
213
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
214
+ <CardTitle className="text-sm font-medium">Total Audience</CardTitle>
215
+ <Users className="h-4 w-4 text-muted-foreground" />
216
+ </CardHeader>
217
+ <CardContent>
218
+ <div className="text-2xl font-bold">{analytics?.totalFollowers?.toLocaleString() || 0}</div>
219
+ <p className="text-xs text-muted-foreground">Across all platforms</p>
220
+ </CardContent>
221
+ </Card>
222
+ <Card>
223
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
224
+ <CardTitle className="text-sm font-medium">Total Reach</CardTitle>
225
+ <Eye className="h-4 w-4 text-muted-foreground" />
226
+ </CardHeader>
227
+ <CardContent>
228
+ <div className="text-2xl font-bold">{analytics?.totalReach?.toLocaleString() || 0}</div>
229
+ <p className="text-xs text-muted-foreground">Views & Impressions</p>
230
+ </CardContent>
231
+ </Card>
232
+ </div>
233
 
234
+ {/* Platform Breakdown */}
235
+ <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
236
+ {analytics?.platforms?.map((p) => (
237
+ <Card key={p.platform}>
238
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
239
+ <CardTitle className="text-base font-medium capitalize flex items-center gap-2">
240
+ {p.platform === 'facebook' && <SiFacebook className="h-4 w-4 text-[#1877F2]" />}
241
+ {p.platform === 'linkedin' && <Linkedin className="h-4 w-4 text-[#0A66C2] fill-current" />}
242
+ {p.platform === 'youtube' && <SiYoutube className="h-4 w-4 text-[#FF0000]" />}
243
+ {p.name || p.platform}
244
+ </CardTitle>
245
+ </CardHeader>
246
+ <CardContent className="space-y-4">
247
+ <div className="flex items-center justify-between">
248
+ <span className="text-sm text-muted-foreground">Followers/Subs</span>
249
+ <span className="font-bold">{p.followers?.toLocaleString()}</span>
250
+ </div>
251
+ <div className="flex items-center justify-between">
252
+ <span className="text-sm text-muted-foreground">Reach/Views</span>
253
+ <span className="font-bold">{p.reach?.toLocaleString()}</span>
254
+ </div>
255
+ </CardContent>
256
+ </Card>
257
+ ))}
258
+ </div>
259
+ </>
260
+ )}
261
+ </TabsContent>
262
+ </Tabs>
263
  </div>
264
  );
265
  }
app/dashboard/social/posts/new/page.tsx CHANGED
@@ -1,23 +1,678 @@
1
- import { auth } from "@/auth";
2
- import { db } from "@/db";
3
- import { connectedAccounts } from "@/db/schema";
4
- import { eq } from "drizzle-orm";
5
- import { redirect } from "next/navigation";
6
- import { PostCreatorForm } from "./post-creator-form";
7
-
8
- export default async function NewPostPage() {
9
- const session = await auth();
10
- if (!session?.user?.id) redirect("/auth/signin");
11
-
12
- const accounts = await db.query.connectedAccounts.findMany({
13
- where: eq(connectedAccounts.userId, session.user.id),
14
- columns: {
15
- id: true,
16
- name: true,
17
- provider: true,
18
- providerAccountId: true
19
- }
20
- });
21
-
22
- return <PostCreatorForm accounts={accounts} />;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  }
 
1
+ "use client";
2
+
3
+ import { useState, useEffect } from "react";
4
+ import { ConnectedAccount } from "@/types";
5
+ import { useRouter } from "next/navigation";
6
+ import { Button } from "@/components/ui/button";
7
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
8
+ import { Textarea } from "@/components/ui/textarea";
9
+ import { Input } from "@/components/ui/input";
10
+ import { Label } from "@/components/ui/label";
11
+ import { Checkbox } from "@/components/ui/checkbox";
12
+ import { Loader2, Wand2, Upload, X, AlertCircle, Linkedin, ChevronLeft, Calendar as CalendarIcon, Plus, Check, ChevronsUpDown } from "lucide-react";
13
+ import { SiFacebook, SiYoutube } from "@icons-pack/react-simple-icons";
14
+ import { useApi } from "@/hooks/use-api";
15
+ import { toast } from "sonner";
16
+ import { Skeleton } from "@/components/ui/skeleton";
17
+ import Link from "next/link";
18
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
19
+ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
20
+ import { Calendar } from "@/components/ui/calendar";
21
+ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
22
+ import { cn } from "@/lib/utils";
23
+ import { format } from "date-fns";
24
+ import Image from "next/image";
25
+
26
+ export default function CreatePostPage() {
27
+ const router = useRouter();
28
+ const { get, post } = useApi();
29
+
30
+ // State
31
+ const [connectedAccounts, setConnectedAccounts] = useState<ConnectedAccount[]>([]);
32
+ const [loadingAccounts, setLoadingAccounts] = useState(true);
33
+ const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>([]);
34
+
35
+ // Post Data
36
+ const [content, setContent] = useState("");
37
+ const [title, setTitle] = useState("");
38
+
39
+ // Dynamic Fields
40
+ const [tags, setTags] = useState<string[]>([]);
41
+ const [tagInput, setTagInput] = useState("");
42
+
43
+ interface Playlist {
44
+ id: string;
45
+ title: string;
46
+ }
47
+ const [playlists, setPlaylists] = useState<Playlist[]>([]);
48
+ const [playlist, setPlaylist] = useState(""); // Stores ID
49
+ const [playlistOpen, setPlaylistOpen] = useState(false);
50
+ const [loadingPlaylists, setLoadingPlaylists] = useState(false);
51
+ const [playlistSearch, setPlaylistSearch] = useState(""); // For create
52
+
53
+ // YouTube specific
54
+ const [category, setCategory] = useState("");
55
+
56
+ const [mediaFile, setMediaFile] = useState<File | null>(null);
57
+ const [mediaPreview, setMediaPreview] = useState<string | null>(null);
58
+
59
+ const [thumbnailFile, setThumbnailFile] = useState<File | null>(null);
60
+ const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(null);
61
+
62
+ const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
63
+
64
+ const [captionLoading, setCaptionLoading] = useState(false);
65
+ const [tagsLoading, setTagsLoading] = useState(false);
66
+ const [uploading, setUploading] = useState(false);
67
+
68
+ // Fetch accounts
69
+ useEffect(() => {
70
+ setLoadingAccounts(true);
71
+ get("/api/settings").then((data: unknown) => {
72
+ const typedData = data as { connectedAccounts: ConnectedAccount[] } | null;
73
+ if (typedData?.connectedAccounts) {
74
+ setConnectedAccounts(typedData.connectedAccounts);
75
+ // If YouTube is connected, fetch playlists
76
+ if (typedData.connectedAccounts.some(acc => acc.provider === 'youtube')) {
77
+ fetchPlaylists();
78
+ }
79
+ }
80
+ }).finally(() => {
81
+ setLoadingAccounts(false);
82
+ });
83
+ // eslint-disable-next-line react-hooks/exhaustive-deps
84
+ }, [get]);
85
+
86
+ const fetchPlaylists = async () => {
87
+ setLoadingPlaylists(true);
88
+ try {
89
+ const res = await get("/api/social/youtube/playlists");
90
+ const data = res as { playlists: Playlist[] };
91
+ if (data.playlists) {
92
+ setPlaylists(data.playlists);
93
+ }
94
+ } catch (error) {
95
+ console.error("Failed to fetch playlists", error);
96
+ } finally {
97
+ setLoadingPlaylists(false);
98
+ }
99
+ };
100
+
101
+ const handleCreatePlaylist = async (name: string) => {
102
+ setLoadingPlaylists(true);
103
+ try {
104
+ const res = await post("/api/social/youtube/playlists", { title: name });
105
+ const data = res as { playlist: Playlist };
106
+ if (data.playlist) {
107
+ const newPlaylist = data.playlist;
108
+ setPlaylists([...playlists, newPlaylist]);
109
+ setPlaylist(newPlaylist.id);
110
+ toast.success(`Created playlist: ${newPlaylist.title}`);
111
+ setPlaylistOpen(false);
112
+ setPlaylistSearch("");
113
+ }
114
+ } catch (error) {
115
+ console.error("Failed to create playlist", error);
116
+ toast.error("Failed to create playlist");
117
+ } finally {
118
+ setLoadingPlaylists(false);
119
+ }
120
+ };
121
+
122
+ // Handle File Selection
123
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>, type: 'media' | 'thumbnail') => {
124
+ if (e.target.files && e.target.files[0]) {
125
+ const file = e.target.files[0];
126
+ const reader = new FileReader();
127
+ reader.onloadend = () => {
128
+ if (type === 'media') {
129
+ setMediaFile(file);
130
+ setMediaPreview(reader.result as string);
131
+ } else {
132
+ setThumbnailFile(file);
133
+ setThumbnailPreview(reader.result as string);
134
+ }
135
+ };
136
+ reader.readAsDataURL(file);
137
+ }
138
+ };
139
+
140
+ // Handle AI Generation
141
+ const handleGenerateAI = async () => {
142
+ if (!content && !mediaFile && !title) {
143
+ toast.error("Please provide some context or topic for AI generation");
144
+ return;
145
+ }
146
+
147
+ setCaptionLoading(true);
148
+ try {
149
+ const res = await fetch("/api/ai/generate-content", {
150
+ method: "POST",
151
+ headers: { "Content-Type": "application/json" },
152
+ body: JSON.stringify({
153
+ prompt: `${title ? `Title: ${title}. ` : ''}${content}` || "A generic engaging post",
154
+ type: "caption",
155
+ context: "Social Media Marketing"
156
+ })
157
+ });
158
+ const data = await res.json();
159
+ if (data.content) {
160
+ setContent(data.content.trim());
161
+ toast.success("AI Caption Generated!");
162
+ }
163
+ } catch (error) {
164
+ console.error(error);
165
+ toast.error("Failed to generate content");
166
+ } finally {
167
+ setCaptionLoading(false);
168
+ }
169
+ };
170
+
171
+ // Handle Tags
172
+ const handleAddTag = (e: React.KeyboardEvent) => {
173
+ if (e.key === 'Enter' || e.key === ',') {
174
+ e.preventDefault();
175
+ const val = tagInput.trim();
176
+ if (val && !tags.includes(val)) {
177
+ setTags([...tags, val]);
178
+ setTagInput("");
179
+ }
180
+ }
181
+ };
182
+ const removeTag = (tag: string) => {
183
+ setTags(tags.filter(t => t !== tag));
184
+ };
185
+
186
+ // Submit Post
187
+ const handleSubmit = async () => {
188
+ if (selectedPlatforms.length === 0) {
189
+ toast.error("Please select at least one platform");
190
+ return;
191
+ }
192
+ if (!content && !mediaFile && !title) {
193
+ toast.error("Please add content, title or media");
194
+ return;
195
+ }
196
+
197
+ setUploading(true);
198
+ try {
199
+ let mediaUrl = null;
200
+ let thumbnailUrl = null;
201
+
202
+ // 1. Upload Media
203
+ if (mediaFile) {
204
+ const formData = new FormData();
205
+ formData.append("file", mediaFile);
206
+ const uploadRes = await fetch("/api/upload", { method: "POST", body: formData });
207
+ if (!uploadRes.ok) throw new Error("Upload failed");
208
+ const uploadData = await uploadRes.json();
209
+ mediaUrl = uploadData.url;
210
+ }
211
+
212
+ // 2. Upload Thumbnail
213
+ if (thumbnailFile) {
214
+ const formData = new FormData();
215
+ formData.append("file", thumbnailFile);
216
+ const uploadRes = await fetch("/api/upload", { method: "POST", body: formData });
217
+ if (!uploadRes.ok) throw new Error("Thumbnail upload failed");
218
+ const uploadData = await uploadRes.json();
219
+ thumbnailUrl = uploadData.url;
220
+ }
221
+
222
+ // 3. Create Post
223
+ const result = await post("/api/social/posts", {
224
+ content,
225
+ title,
226
+ mediaUrl,
227
+ thumbnailUrl,
228
+ tags,
229
+ category,
230
+ playlist,
231
+ scheduledAt: scheduledDate?.toISOString(),
232
+ platforms: selectedPlatforms
233
+ }) as { success?: boolean; error?: string };
234
+
235
+ if (result.success) {
236
+ toast.success(scheduledDate ? "Post scheduled successfully!" : "Post submitted successfully!");
237
+ router.push("/dashboard/social");
238
+ } else {
239
+ toast.error(result.error || "Post creation failed");
240
+ }
241
+
242
+ } catch (error) {
243
+ console.error(error);
244
+ toast.error("Something went wrong");
245
+ } finally {
246
+ setUploading(false);
247
+ }
248
+ };
249
+
250
+ const togglePlatform = (id: string) => {
251
+ setSelectedPlatforms(prev => prev.includes(id) ? prev.filter(p => p !== id) : [...prev, id]);
252
+ };
253
+
254
+ return (
255
+ <div className="flex flex-col gap-6 p-6 max-w-5xl mx-auto relative">
256
+ {/* Blocking Overlay */}
257
+ {uploading && (
258
+ <div className="absolute inset-0 z-50 bg-background/50 backdrop-blur-sm flex items-center justify-center rounded-lg">
259
+ <div className="flex flex-col items-center gap-4 bg-background p-8 rounded-lg shadow-lg border animate-in fade-in zoom-in duration-300">
260
+ <Loader2 className="h-12 w-12 animate-spin text-primary" />
261
+ <div className="text-center space-y-1">
262
+ <h3 className="font-semibold text-lg">{scheduledDate ? "Scheduling Post" : "Publishing Post"}</h3>
263
+ <p className="text-muted-foreground text-sm">Uploading media and processing...</p>
264
+ </div>
265
+ </div>
266
+ </div>
267
+ )}
268
+
269
+ <div>
270
+ <Button variant="ghost" className="mb-4 pl-0 hover:bg-transparent hover:text-primary" asChild>
271
+ <Link href="/dashboard/social">
272
+ <ChevronLeft className="mr-2 h-4 w-4" />
273
+ Back to Dashboard
274
+ </Link>
275
+ </Button>
276
+ <div className="flex items-center justify-between">
277
+ <div>
278
+ <h1 className="text-3xl font-bold tracking-tight">Create New Post</h1>
279
+ <p className="text-muted-foreground">Compose and publish content to your social networks.</p>
280
+ </div>
281
+ {/* Schedule Toggle/Indicator could go here */}
282
+ </div>
283
+ </div>
284
+
285
+ <div className="grid gap-6 md:grid-cols-[2fr_1fr]">
286
+ <div className="space-y-6">
287
+ {/* Main Content Card */}
288
+ <Card>
289
+ <CardHeader>
290
+ <CardTitle>Post Details</CardTitle>
291
+ </CardHeader>
292
+ <CardContent className="space-y-4">
293
+ <div className="space-y-2">
294
+ <Label>Title (Required for YouTube / Articles)</Label>
295
+ <Input
296
+ placeholder="Enter a catchy title..."
297
+ value={title}
298
+ onChange={(e) => setTitle(e.target.value)}
299
+ disabled={uploading}
300
+ />
301
+ </div>
302
+
303
+ <div className="space-y-2">
304
+ <Label>Post Content</Label>
305
+ <div className="relative">
306
+ <Textarea
307
+ placeholder="What's on your mind? (Use AI to help you write)"
308
+ className={`min-h-[150px] pr-24 resize-none transition-all ${captionLoading ? "opacity-50" : ""}`}
309
+ value={content}
310
+ onChange={(e) => setContent(e.target.value)}
311
+ disabled={captionLoading || uploading}
312
+ />
313
+ <Button
314
+ size="sm"
315
+ variant="ghost"
316
+ className="absolute bottom-2 right-2 text-purple-600 hover:text-purple-700 hover:bg-purple-50"
317
+ onClick={handleGenerateAI}
318
+ disabled={captionLoading || uploading}
319
+ >
320
+ <Wand2 className={`h-4 w-4 mr-1 ${captionLoading ? "animate-spin" : ""}`} />
321
+ {captionLoading ? "Generating..." : "Generate AI"}
322
+ </Button>
323
+ </div>
324
+ </div>
325
+
326
+ {/* Tags Input */}
327
+ <div className="space-y-2">
328
+ <div className="flex items-center justify-between">
329
+ <Label>Tags</Label>
330
+ <Button
331
+ size="sm"
332
+ variant="ghost"
333
+ className="h-6 text-xs text-purple-600 hover:text-purple-700 hover:bg-purple-50 px-2"
334
+ onClick={async () => {
335
+ if (!content && !title) {
336
+ toast.error("Add content or title first to generate tags");
337
+ return;
338
+ }
339
+ setTagsLoading(true);
340
+ try {
341
+ const res = await fetch("/api/ai/generate-content", {
342
+ method: "POST",
343
+ headers: { "Content-Type": "application/json" },
344
+ body: JSON.stringify({
345
+ prompt: `${title} ${content}`,
346
+ type: "tags",
347
+ context: "Social Media Optimization"
348
+ })
349
+ });
350
+ const data = await res.json();
351
+ if (data.content) {
352
+ const newTags = data.content.split(',').map((t: string) => t.trim().replace(/^#/, '')).filter(Boolean);
353
+ setTags(prev => [...new Set([...prev, ...newTags])]); // Merge unique
354
+ toast.success(`Generated ${newTags.length} tags`);
355
+ }
356
+ } catch (e) {
357
+ console.error(e);
358
+ toast.error("Failed to generate tags");
359
+ } finally {
360
+ setTagsLoading(false);
361
+ }
362
+ }}
363
+ disabled={tagsLoading || uploading}
364
+ >
365
+ <Wand2 className={`h-3 w-3 mr-1 ${tagsLoading ? "animate-spin" : ""}`} />
366
+ {tagsLoading ? "Generating..." : "Auto Tags"}
367
+ </Button>
368
+ </div>
369
+ <div className="flex flex-wrap gap-2 border rounded-md p-2 bg-background focus-within:ring-1 focus-within:ring-ring">
370
+ {tags.map(tag => (
371
+ <span key={tag} className="flex items-center bg-secondary text-secondary-foreground text-xs px-2 py-1 rounded-full">
372
+ #{tag}
373
+ <button onClick={() => removeTag(tag)} className="ml-1 hover:text-destructive"><X className="h-3 w-3" /></button>
374
+ </span>
375
+ ))}
376
+ <input
377
+ className="flex-1 bg-transparent outline-none text-sm min-w-[100px]"
378
+ placeholder="Type and press Enter..."
379
+ value={tagInput}
380
+ onChange={(e) => setTagInput(e.target.value)}
381
+ onKeyDown={handleAddTag}
382
+ disabled={uploading}
383
+ />
384
+ </div>
385
+ </div>
386
+ </CardContent>
387
+ </Card>
388
+
389
+ {/* Media Card */}
390
+ <Card>
391
+ <CardHeader>
392
+ <CardTitle>Media & Options</CardTitle>
393
+ </CardHeader>
394
+ <CardContent className="space-y-6">
395
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
396
+ {/* Primary Media */}
397
+ <div className="space-y-2">
398
+ <Label>Primary Media</Label>
399
+ {mediaPreview ? (
400
+ <div className="relative rounded-lg overflow-hidden border bg-muted aspect-video flex items-center justify-center group">
401
+ {mediaFile?.type.startsWith('video') ? (
402
+ <video src={mediaPreview} controls className="max-h-full max-w-full" />
403
+ ) : (
404
+ <Image
405
+ src={mediaPreview}
406
+ alt="Preview"
407
+ fill
408
+ className="object-contain"
409
+ unoptimized
410
+ />
411
+ )}
412
+ <Button
413
+ variant="destructive"
414
+ size="icon"
415
+ className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity"
416
+ onClick={() => { setMediaFile(null); setMediaPreview(null); }}
417
+ disabled={uploading}
418
+ >
419
+ <X className="h-4 w-4" />
420
+ </Button>
421
+ </div>
422
+ ) : (
423
+ <div className={`border-2 border-dashed rounded-lg p-6 text-center relative ${uploading ? "opacity-50" : "hover:bg-muted/50 cursor-pointer"}`}>
424
+ <input
425
+ type="file"
426
+ className="absolute inset-0 opacity-0 cursor-pointer"
427
+ onChange={(e) => handleFileChange(e, 'media')}
428
+ accept="image/*,video/*,video/x-matroska,video/x-flv,video/webm"
429
+ disabled={uploading}
430
+ />
431
+ <div className="flex flex-col items-center gap-2 text-muted-foreground">
432
+ <Upload className="h-6 w-6" />
433
+ <p className="text-xs font-medium">Upload Media</p>
434
+ <p className="text-[10px] text-muted-foreground">MP4, MOV, FLV, MKV, PNG, JPG</p>
435
+ </div>
436
+ </div>
437
+ )}
438
+ </div>
439
+
440
+ {/* Thumbnail */}
441
+ <div className="space-y-2">
442
+ <Label>Thumbnail (Video only)</Label>
443
+ {thumbnailPreview ? (
444
+ <div className="relative rounded-lg overflow-hidden border bg-muted aspect-video flex items-center justify-center group">
445
+ <Image
446
+ src={thumbnailPreview}
447
+ alt="Thumbnail Preview"
448
+ fill
449
+ className="object-cover"
450
+ unoptimized
451
+ />
452
+ <Button
453
+ variant="destructive"
454
+ size="icon"
455
+ className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity"
456
+ onClick={() => { setThumbnailFile(null); setThumbnailPreview(null); }}
457
+ disabled={uploading}
458
+ >
459
+ <X className="h-4 w-4" />
460
+ </Button>
461
+ </div>
462
+ ) : (
463
+ <div className={`border-2 border-dashed rounded-lg p-6 text-center relative ${uploading ? "opacity-50" : "hover:bg-muted/50 cursor-pointer"}`}>
464
+ <input
465
+ type="file"
466
+ className="absolute inset-0 opacity-0 cursor-pointer"
467
+ onChange={(e) => handleFileChange(e, 'thumbnail')}
468
+ accept="image/*"
469
+ disabled={uploading}
470
+ />
471
+ <div className="flex flex-col items-center gap-2 text-muted-foreground">
472
+ <Upload className="h-6 w-6" />
473
+ <p className="text-xs font-medium">Upload Thumbnail</p>
474
+ </div>
475
+ </div>
476
+ )}
477
+ </div>
478
+ </div>
479
+
480
+ {/* Additional Metadata */}
481
+ <div className="grid grid-cols-2 gap-4">
482
+ <div className="space-y-2">
483
+ <Label>Category (YouTube)</Label>
484
+ <Select value={category} onValueChange={setCategory} disabled={uploading}>
485
+ <SelectTrigger>
486
+ <SelectValue placeholder="Select Category" />
487
+ </SelectTrigger>
488
+ <SelectContent>
489
+ <SelectItem value="22">People & Blogs</SelectItem>
490
+ <SelectItem value="28">Science & Technology</SelectItem>
491
+ <SelectItem value="27">Education</SelectItem>
492
+ <SelectItem value="24">Entertainment</SelectItem>
493
+ <SelectItem value="10">Music</SelectItem>
494
+ <SelectItem value="17">Sports</SelectItem>
495
+ <SelectItem value="20">Gaming</SelectItem>
496
+ </SelectContent>
497
+ </Select>
498
+ </div>
499
+ <div className="space-y-2">
500
+ <Label>Playlist (YouTube)</Label>
501
+ <Popover open={playlistOpen} onOpenChange={setPlaylistOpen}>
502
+ <PopoverTrigger asChild>
503
+ <Button
504
+ variant="outline"
505
+ role="combobox"
506
+ aria-expanded={playlistOpen}
507
+ className="w-full justify-between"
508
+ disabled={uploading}
509
+ >
510
+ {playlist
511
+ ? playlists.find((p) => p.id === playlist)?.title
512
+ : "Select or Create Playlist..."}
513
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
514
+ </Button>
515
+ </PopoverTrigger>
516
+ <PopoverContent className="w-[300px] p-0">
517
+ <Command>
518
+ <CommandInput
519
+ placeholder="Search or create playlist..."
520
+ value={playlistSearch}
521
+ onValueChange={setPlaylistSearch}
522
+ />
523
+ <CommandList>
524
+ <CommandEmpty className="py-2 px-2">
525
+ <div className="flex flex-col gap-2">
526
+ <p className="text-sm text-muted-foreground">No playlist found.</p>
527
+ {playlistSearch && (
528
+ <Button
529
+ variant="secondary"
530
+ size="sm"
531
+ className="w-full h-8 justify-start"
532
+ onClick={() => handleCreatePlaylist(playlistSearch)}
533
+ disabled={loadingPlaylists}
534
+ >
535
+ <Plus className="h-3 w-3 mr-2" />
536
+ Create &quot;{playlistSearch}&quot;
537
+ </Button>
538
+ )}
539
+ </div>
540
+ </CommandEmpty>
541
+ <CommandGroup>
542
+ {playlists.map((p) => (
543
+ <CommandItem
544
+ key={p.id}
545
+ value={p.title}
546
+ onSelect={() => {
547
+ setPlaylist(p.id === playlist ? "" : p.id);
548
+ setPlaylistOpen(false);
549
+ }}
550
+ >
551
+ <Check
552
+ className={cn(
553
+ "mr-2 h-4 w-4",
554
+ playlist === p.id ? "opacity-100" : "opacity-0"
555
+ )}
556
+ />
557
+ {p.title}
558
+ </CommandItem>
559
+ ))}
560
+ </CommandGroup>
561
+ </CommandList>
562
+ </Command>
563
+ </PopoverContent>
564
+ </Popover>
565
+ </div>
566
+ </div>
567
+ </CardContent>
568
+ </Card>
569
+ </div>
570
+
571
+ <div className="space-y-6">
572
+ {/* Platform Selector */}
573
+ <Card>
574
+ <CardHeader>
575
+ <CardTitle className="text-lg">Select Platforms</CardTitle>
576
+ </CardHeader>
577
+ <CardContent className="space-y-3">
578
+ {loadingAccounts ? (
579
+ <div className="space-y-3">
580
+ <Skeleton className="h-10 w-full rounded-lg" />
581
+ <Skeleton className="h-10 w-full rounded-lg" />
582
+ </div>
583
+ ) : connectedAccounts.length === 0 ? (
584
+ <div className="text-sm text-yellow-600 bg-yellow-50 p-3 rounded flex items-start gap-2">
585
+ <AlertCircle className="h-4 w-4 mt-0.5" />
586
+ <div>No accounts connected. Go to Settings to connect.</div>
587
+ </div>
588
+ ) : (
589
+ connectedAccounts.map(account => (
590
+ <div key={account.id} className="flex items-center space-x-2 border p-3 rounded-lg hover:bg-muted/50 transition-colors">
591
+ <Checkbox
592
+ id={account.id}
593
+ checked={selectedPlatforms.includes(account.id)}
594
+ onCheckedChange={() => togglePlatform(account.id)}
595
+ disabled={uploading}
596
+ />
597
+ <Label htmlFor={account.id} className="flex items-center gap-2 cursor-pointer flex-1">
598
+ {account.provider === 'facebook' && <SiFacebook className="h-4 w-4 text-[#1877F2]" />}
599
+ {account.provider === 'linkedin' && <Linkedin className="h-4 w-4 text-[#0A66C2] fill-current" />}
600
+ {account.provider === 'youtube' && <SiYoutube className="h-4 w-4 text-[#FF0000]" />}
601
+ <span className="truncate">{account.name || account.provider}</span>
602
+ </Label>
603
+ </div>
604
+ ))
605
+ )}
606
+ </CardContent>
607
+ </Card>
608
+
609
+ {/* Scheduling Card */}
610
+ <Card>
611
+ <CardHeader>
612
+ <CardTitle className="text-lg">Scheduling</CardTitle>
613
+ </CardHeader>
614
+ <CardContent>
615
+ <div className="flex flex-col gap-2">
616
+ <Label>Publish Date & Time (Optional)</Label>
617
+ <Popover>
618
+ <PopoverTrigger asChild>
619
+ <Button
620
+ variant={"outline"}
621
+ className={cn(
622
+ "w-full justify-start text-left font-normal",
623
+ !scheduledDate && "text-muted-foreground"
624
+ )}
625
+ >
626
+ <CalendarIcon className="mr-2 h-4 w-4" />
627
+ {scheduledDate ? format(scheduledDate, "PPP p") : <span>Pick a date</span>}
628
+ </Button>
629
+ </PopoverTrigger>
630
+ <PopoverContent className="w-auto p-0">
631
+ <Calendar
632
+ mode="single"
633
+ selected={scheduledDate}
634
+ onSelect={setScheduledDate}
635
+ initialFocus
636
+ />
637
+ <div className="p-3 border-t">
638
+ <Input
639
+ type="time"
640
+ onChange={(e) => {
641
+ const date = scheduledDate || new Date();
642
+ const [hours, minutes] = e.target.value.split(':');
643
+ date.setHours(parseInt(hours), parseInt(minutes));
644
+ setScheduledDate(new Date(date));
645
+ }}
646
+ />
647
+ </div>
648
+ </PopoverContent>
649
+ </Popover>
650
+ {scheduledDate && (
651
+ <Button variant="ghost" size="sm" onClick={() => setScheduledDate(undefined)} className="text-muted-foreground h-auto p-0">
652
+ Clear Schedule
653
+ </Button>
654
+ )}
655
+ </div>
656
+ </CardContent>
657
+ </Card>
658
+
659
+ {/* Submit Button */}
660
+ <div className="flex flex-col gap-2">
661
+ <Button
662
+ size="lg"
663
+ className="w-full"
664
+ onClick={handleSubmit}
665
+ disabled={uploading || selectedPlatforms.length === 0}
666
+ >
667
+ {uploading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
668
+ {uploading ? "Processing..." : (scheduledDate ? `Schedule Post` : "Post Now")}
669
+ </Button>
670
+ <p className="text-xs text-center text-muted-foreground">
671
+ {scheduledDate ? "Post will be saved internally and published at the scheduled time." : "Post will be published immediately to selected platforms."}
672
+ </p>
673
+ </div>
674
+ </div>
675
+ </div>
676
+ </div>
677
+ );
678
  }
app/dashboard/social/posts/new/post-creator-form.tsx CHANGED
@@ -11,6 +11,7 @@ import { toast } from "sonner";
11
  import { useRouter } from "next/navigation";
12
  import { Loader2, ArrowLeft } from "lucide-react";
13
  import Link from "next/link";
 
14
  // If DatePicker doesn't exist, we'll use input type="datetime-local" for MVP.
15
 
16
  interface ConnectedAccount {
@@ -131,8 +132,9 @@ export function PostCreatorForm({ accounts }: PostCreatorFormProps) {
131
  onChange={(e) => setImageUrl(e.target.value)}
132
  />
133
  {imageUrl && (
134
- // eslint-disable-next-line @next/next/no-img-element
135
- <img src={imageUrl} alt="Preview" className="w-full h-48 object-cover rounded mt-2 border" />
 
136
  )}
137
  <p className="text-xs text-muted-foreground">Direct link to image (JPG/PNG)</p>
138
  </div>
 
11
  import { useRouter } from "next/navigation";
12
  import { Loader2, ArrowLeft } from "lucide-react";
13
  import Link from "next/link";
14
+ import Image from "next/image";
15
  // If DatePicker doesn't exist, we'll use input type="datetime-local" for MVP.
16
 
17
  interface ConnectedAccount {
 
132
  onChange={(e) => setImageUrl(e.target.value)}
133
  />
134
  {imageUrl && (
135
+ <div className="relative w-full h-48 mt-2 rounded border overflow-hidden">
136
+ <Image src={imageUrl} alt="Preview" fill className="object-cover" unoptimized />
137
+ </div>
138
  )}
139
  <p className="text-xs text-muted-foreground">Direct link to image (JPG/PNG)</p>
140
  </div>
app/dashboard/workflows/builder/[id]/page.tsx CHANGED
@@ -46,13 +46,8 @@ export default function WorkflowBuilderPage() {
46
  init();
47
  }, [params.id, get, router, toast]);
48
 
49
- const [isManualSaving, setIsManualSaving] = useState(false);
50
-
51
- const handleSave = async (nodes: Node<NodeData>[], edges: Edge[], options?: { isAutoSave?: boolean }) => {
52
  const isNew = params.id === "new";
53
- const isAutoSave = options?.isAutoSave;
54
-
55
- if (!isAutoSave) setIsManualSaving(true);
56
 
57
  const workflowData = {
58
  ...workflow,
@@ -60,36 +55,29 @@ export default function WorkflowBuilderPage() {
60
  edges,
61
  };
62
 
63
- const apiOptions = isAutoSave ? { skipNotification: true } : undefined;
64
-
65
  let result;
66
  if (isNew) {
67
- result = await post<{ workflow: AutomationWorkflow }>("/api/workflows", workflowData, apiOptions);
68
  } else {
69
- result = await patch<{ workflow: AutomationWorkflow }>(`/api/workflows/${params.id}`, workflowData, apiOptions);
70
  }
71
 
72
  if (result && result.workflow) {
73
- if (!isAutoSave) {
74
- toast({
75
- title: "Success",
76
- description: "Workflow saved successfully",
77
- });
78
- }
79
-
80
  if (isNew && result.workflow.id) {
81
  // Update URL without reloading so we stay in edit mode
82
  router.replace(`/dashboard/workflows/builder/${result.workflow.id}`);
83
  }
84
- } else if (!isAutoSave) {
85
  toast({
86
  title: "Error",
87
  description: "Failed to save workflow",
88
  variant: "destructive",
89
  });
90
  }
91
-
92
- if (!isAutoSave) setIsManualSaving(false);
93
  };
94
 
95
  const [isSettingsOpen, setIsSettingsOpen] = useState(false);
@@ -138,7 +126,7 @@ export default function WorkflowBuilderPage() {
138
  disabled={loading}
139
  className="w-full sm:w-auto"
140
  >
141
- {isManualSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
142
  Save Workflow
143
  </Button>
144
  </div>
 
46
  init();
47
  }, [params.id, get, router, toast]);
48
 
49
+ const handleSave = async (nodes: Node<NodeData>[], edges: Edge[]) => {
 
 
50
  const isNew = params.id === "new";
 
 
 
51
 
52
  const workflowData = {
53
  ...workflow,
 
55
  edges,
56
  };
57
 
 
 
58
  let result;
59
  if (isNew) {
60
+ result = await post<{ workflow: AutomationWorkflow }>("/api/workflows", workflowData);
61
  } else {
62
+ result = await patch<{ workflow: AutomationWorkflow }>(`/api/workflows/${params.id}`, workflowData);
63
  }
64
 
65
  if (result && result.workflow) {
66
+ toast({
67
+ title: "Success",
68
+ description: "Workflow saved successfully",
69
+ });
 
 
 
70
  if (isNew && result.workflow.id) {
71
  // Update URL without reloading so we stay in edit mode
72
  router.replace(`/dashboard/workflows/builder/${result.workflow.id}`);
73
  }
74
+ } else {
75
  toast({
76
  title: "Error",
77
  description: "Failed to save workflow",
78
  variant: "destructive",
79
  });
80
  }
 
 
81
  };
82
 
83
  const [isSettingsOpen, setIsSettingsOpen] = useState(false);
 
126
  disabled={loading}
127
  className="w-full sm:w-auto"
128
  >
129
+ {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
130
  Save Workflow
131
  </Button>
132
  </div>
components/dashboard/business-table.tsx CHANGED
@@ -15,10 +15,13 @@ import { Mail, ExternalLink, MoreHorizontal } from "lucide-react";
15
  import { Checkbox } from "@/components/ui/checkbox";
16
 
17
 
 
 
18
  interface BusinessTableProps {
19
  businesses: Business[];
20
  onViewDetails: (business: Business) => void;
21
  onSendEmail: (business: Business) => void;
 
22
  }
23
 
24
  export function BusinessTable({
@@ -27,6 +30,7 @@ export function BusinessTable({
27
  onSendEmail,
28
  selectedIds = [],
29
  onSelectionChange,
 
30
  }: BusinessTableProps & {
31
  selectedIds?: string[];
32
  onSelectionChange?: (ids: string[]) => void;
@@ -62,6 +66,46 @@ export function BusinessTable({
62
  }
63
  };
64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  return (
66
  <Table>
67
  <TableHeader>
@@ -85,7 +129,14 @@ export function BusinessTable({
85
  </TableRow>
86
  </TableHeader>
87
  <TableBody>
88
- {businesses.map((business) => (
 
 
 
 
 
 
 
89
  <TableRow key={business.id}>
90
  {onSelectionChange && (
91
  <TableCell>
@@ -154,7 +205,7 @@ export function BusinessTable({
154
  </div>
155
  </TableCell>
156
  </TableRow>
157
- ))}
158
  </TableBody>
159
  </Table>
160
  );
 
15
  import { Checkbox } from "@/components/ui/checkbox";
16
 
17
 
18
+ import { Skeleton } from "@/components/ui/skeleton";
19
+
20
  interface BusinessTableProps {
21
  businesses: Business[];
22
  onViewDetails: (business: Business) => void;
23
  onSendEmail: (business: Business) => void;
24
+ isLoading?: boolean;
25
  }
26
 
27
  export function BusinessTable({
 
30
  onSendEmail,
31
  selectedIds = [],
32
  onSelectionChange,
33
+ isLoading = false,
34
  }: BusinessTableProps & {
35
  selectedIds?: string[];
36
  onSelectionChange?: (ids: string[]) => void;
 
66
  }
67
  };
68
 
69
+ if (isLoading) {
70
+ return (
71
+ <div className="w-full">
72
+ <div className="flex items-center py-4">
73
+ {/* Optional header skeleton if needed, but table structure usually suffices */}
74
+ </div>
75
+ <div className="rounded-md border">
76
+ <Table>
77
+ <TableHeader>
78
+ <TableRow>
79
+ <TableHead className="w-[50px]"><Skeleton className="h-4 w-4" /></TableHead>
80
+ <TableHead>Business Name</TableHead>
81
+ <TableHead>Website</TableHead>
82
+ <TableHead>Email</TableHead>
83
+ <TableHead>Phone</TableHead>
84
+ <TableHead>Category</TableHead>
85
+ <TableHead>Email Status</TableHead>
86
+ <TableHead>Actions</TableHead>
87
+ </TableRow>
88
+ </TableHeader>
89
+ <TableBody>
90
+ {Array.from({ length: 5 }).map((_, i) => (
91
+ <TableRow key={i}>
92
+ <TableCell><Skeleton className="h-4 w-4" /></TableCell>
93
+ <TableCell><Skeleton className="h-4 w-[150px]" /></TableCell>
94
+ <TableCell><Skeleton className="h-4 w-[100px]" /></TableCell>
95
+ <TableCell><Skeleton className="h-4 w-[120px]" /></TableCell>
96
+ <TableCell><Skeleton className="h-4 w-[100px]" /></TableCell>
97
+ <TableCell><Skeleton className="h-4 w-[80px]" /></TableCell>
98
+ <TableCell><Skeleton className="h-6 w-[70px] rounded-full" /></TableCell>
99
+ <TableCell><Skeleton className="h-8 w-[80px]" /></TableCell>
100
+ </TableRow>
101
+ ))}
102
+ </TableBody>
103
+ </Table>
104
+ </div>
105
+ </div>
106
+ );
107
+ }
108
+
109
  return (
110
  <Table>
111
  <TableHeader>
 
129
  </TableRow>
130
  </TableHeader>
131
  <TableBody>
132
+ {businesses.length === 0 ? (
133
+ <TableRow>
134
+ <TableCell colSpan={8} className="h-24 text-center">
135
+ No businesses found.
136
+ </TableCell>
137
+ </TableRow>
138
+ ) : (
139
+ businesses.map((business) => (
140
  <TableRow key={business.id}>
141
  {onSelectionChange && (
142
  <TableCell>
 
205
  </div>
206
  </TableCell>
207
  </TableRow>
208
+ )))}
209
  </TableBody>
210
  </Table>
211
  );
components/dashboard/sidebar.tsx CHANGED
@@ -15,6 +15,7 @@ import {
15
  FileText,
16
  CheckSquare,
17
  Share2,
 
18
  } from "lucide-react";
19
  import { Button } from "@/components/ui/button";
20
  import { useState } from "react";
@@ -27,6 +28,7 @@ import { toast } from "sonner";
27
  const navigation = [
28
  { name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
29
  { name: "Businesses", href: "/dashboard/businesses", icon: Building2 },
 
30
  { name: "Workflows", href: "/dashboard/workflows", icon: Workflow },
31
  { name: "Templates", href: "/dashboard/templates", icon: Mail },
32
  { name: "Analytics", href: "/dashboard/analytics", icon: FileText },
 
15
  FileText,
16
  CheckSquare,
17
  Share2,
18
+ Search,
19
  } from "lucide-react";
20
  import { Button } from "@/components/ui/button";
21
  import { useState } from "react";
 
28
  const navigation = [
29
  { name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
30
  { name: "Businesses", href: "/dashboard/businesses", icon: Building2 },
31
+ { name: "Scraper", href: "/dashboard/scraper", icon: Search },
32
  { name: "Workflows", href: "/dashboard/workflows", icon: Workflow },
33
  { name: "Templates", href: "/dashboard/templates", icon: Mail },
34
  { name: "Analytics", href: "/dashboard/analytics", icon: FileText },
components/dashboard/user-nav.tsx CHANGED
@@ -24,7 +24,7 @@ export function UserNav() {
24
  <DropdownMenuTrigger asChild>
25
  <Button variant="ghost" className="relative h-8 w-8 hover:scale-110 rounded-full cursor-pointer">
26
  <Avatar className="h-8 w-8">
27
- <AvatarImage src={user?.image || ""} alt={user?.name || ""} />
28
  <AvatarFallback>{user?.name?.charAt(0) || "U"}</AvatarFallback>
29
  </Avatar>
30
  </Button>
 
24
  <DropdownMenuTrigger asChild>
25
  <Button variant="ghost" className="relative h-8 w-8 hover:scale-110 rounded-full cursor-pointer">
26
  <Avatar className="h-8 w-8">
27
+ <AvatarImage src={user?.image || undefined} alt={user?.name || ""} />
28
  <AvatarFallback>{user?.name?.charAt(0) || "U"}</AvatarFallback>
29
  </Avatar>
30
  </Button>
components/mobile-nav.tsx CHANGED
@@ -1,30 +1,24 @@
1
  "use client";
2
 
3
  import { useState } from "react";
4
- import { X, Menu, LogOut } from "lucide-react";
5
  import { Button } from "@/components/ui/button";
6
  import { cn } from "@/lib/utils";
7
  import Link from "next/link";
8
  import { usePathname } from "next/navigation";
9
- import {
10
- LayoutDashboard,
11
- Building2,
12
- Workflow,
13
- Mail,
14
- Settings,
15
- FileText,
16
- CheckSquare
17
- } from "lucide-react";
18
  import { BuyCoffeeWidget } from "@/components/buy-coffee-widget";
19
  import { SignOutModal } from "@/components/sign-out-modal";
20
 
21
  const navigation = [
22
  { name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
23
  { name: "Businesses", href: "/dashboard/businesses", icon: Building2 },
 
24
  { name: "Workflows", href: "/dashboard/workflows", icon: Workflow },
25
  { name: "Templates", href: "/dashboard/templates", icon: Mail },
26
  { name: "Analytics", href: "/dashboard/analytics", icon: FileText },
27
  { name: "Tasks", href: "/dashboard/tasks", icon: CheckSquare },
 
28
  { name: "Settings", href: "/dashboard/settings", icon: Settings },
29
  ];
30
 
@@ -35,85 +29,63 @@ export function MobileNav() {
35
 
36
  return (
37
  <>
38
- <Button
39
- variant="ghost"
40
- size="icon"
41
- className="md:hidden cursor-pointer"
42
- onClick={() => setIsOpen(true)}
43
- >
44
- <Menu className="h-6 w-6" />
45
- </Button>
46
-
47
- {/* Overlay */}
48
- {isOpen && (
49
- <div
50
- className="fixed inset-0 bg-black/50 z-50 md:hidden"
51
- onClick={() => setIsOpen(false)}
52
- />
53
- )}
54
-
55
- {/* Drawer */}
56
- <div
57
- className={cn(
58
- "fixed top-0 left-0 bottom-0 w-72 bg-background border-r z-50 transform transition-transform duration-300 ease-in-out md:hidden flex flex-col h-dvh",
59
- isOpen ? "translate-x-0" : "-translate-x-full"
60
- )}
61
- >
62
- <div className="flex items-center justify-between p-4 border-b shrink-0">
63
- <div className="flex items-center gap-2">
64
- <div className="h-8 w-8 rounded-lg bg-linear-to-br from-blue-600 to-purple-600 flex items-center justify-center text-white font-bold text-lg">
65
- A
66
- </div>
67
- <span className="text-xl font-bold bg-linear-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
68
- AutoLoop
69
- </span>
70
- </div>
71
- <Button
72
- variant="ghost"
73
- size="icon"
74
- onClick={() => setIsOpen(false)}
75
- className="cursor-pointer"
76
- >
77
- <X className="h-5 w-5" />
78
  </Button>
79
- </div>
 
 
 
 
 
 
 
 
 
 
 
80
 
81
- <nav className="flex-1 overflow-y-auto p-4 space-y-1">
82
- {navigation.map((item) => {
83
- const isActive = pathname === item.href;
84
- const Icon = item.icon;
85
- return (
86
- <Link
87
- key={item.name}
88
- href={item.href}
89
- onClick={() => setIsOpen(false)}
90
- className={cn(
91
- "flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors",
92
- isActive
93
- ? "bg-primary text-primary-foreground"
94
- : "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
95
- )}
96
- >
97
- <Icon className="h-5 w-5 shrink-0" />
98
- {item.name}
99
- </Link>
100
- );
101
- })}
102
- </nav>
103
 
104
- {/* Footer Section */}
105
- <div className="p-4 border-t space-y-4 bg-background shrink-0">
106
- <BuyCoffeeWidget />
107
- <Button
108
- variant="ghost"
109
- className="w-full justify-start cursor-pointer text-muted-foreground hover:text-foreground hover:bg-accent"
110
- onClick={() => setShowSignOutModal(true)}
111
- >
112
- <LogOut className="h-5 w-5 mr-3 shrink-0" />
113
- Sign Out
114
- </Button>
115
- </div>
116
- </div>
 
 
 
117
 
118
  <SignOutModal
119
  isOpen={showSignOutModal}
@@ -122,3 +94,4 @@ export function MobileNav() {
122
  </>
123
  );
124
  }
 
 
1
  "use client";
2
 
3
  import { useState } from "react";
4
+ import { Menu, LogOut, LayoutDashboard, Building2, Workflow, Mail, FileText, CheckSquare, Settings, Share2, Search } from "lucide-react";
5
  import { Button } from "@/components/ui/button";
6
  import { cn } from "@/lib/utils";
7
  import Link from "next/link";
8
  import { usePathname } from "next/navigation";
9
+ import { Sheet, SheetContent, SheetTrigger, SheetHeader, SheetTitle } from "@/components/ui/sheet";
 
 
 
 
 
 
 
 
10
  import { BuyCoffeeWidget } from "@/components/buy-coffee-widget";
11
  import { SignOutModal } from "@/components/sign-out-modal";
12
 
13
  const navigation = [
14
  { name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
15
  { name: "Businesses", href: "/dashboard/businesses", icon: Building2 },
16
+ { name: "Scraper", href: "/dashboard/scraper", icon: Search },
17
  { name: "Workflows", href: "/dashboard/workflows", icon: Workflow },
18
  { name: "Templates", href: "/dashboard/templates", icon: Mail },
19
  { name: "Analytics", href: "/dashboard/analytics", icon: FileText },
20
  { name: "Tasks", href: "/dashboard/tasks", icon: CheckSquare },
21
+ { name: "Social Suite", href: "/dashboard/social", icon: Share2 },
22
  { name: "Settings", href: "/dashboard/settings", icon: Settings },
23
  ];
24
 
 
29
 
30
  return (
31
  <>
32
+ <Sheet open={isOpen} onOpenChange={setIsOpen}>
33
+ <SheetTrigger asChild>
34
+ <Button variant="ghost" size="icon" className="md:hidden">
35
+ <Menu className="h-6 w-6" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  </Button>
37
+ </SheetTrigger>
38
+ <SheetContent side="left" className="w-72 p-0 flex flex-col">
39
+ <SheetHeader className="p-4 border-b text-left">
40
+ <SheetTitle className="flex items-center gap-2">
41
+ <div className="h-8 w-8 rounded-lg bg-linear-to-br from-blue-600 to-purple-600 flex items-center justify-center text-white font-bold text-lg">
42
+ A
43
+ </div>
44
+ <span className="text-xl font-bold bg-linear-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
45
+ AutoLoop
46
+ </span>
47
+ </SheetTitle>
48
+ </SheetHeader>
49
 
50
+ <nav className="flex-1 overflow-y-auto p-4 space-y-1">
51
+ {navigation.map((item) => {
52
+ const isActive = pathname === item.href;
53
+ const Icon = item.icon;
54
+ return (
55
+ <Link
56
+ key={item.name}
57
+ href={item.href}
58
+ onClick={() => setIsOpen(false)}
59
+ className={cn(
60
+ "flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors",
61
+ isActive
62
+ ? "bg-primary text-primary-foreground"
63
+ : "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
64
+ )}
65
+ >
66
+ <Icon className="h-5 w-5 shrink-0" />
67
+ {item.name}
68
+ </Link>
69
+ );
70
+ })}
71
+ </nav>
72
 
73
+ <div className="p-4 border-t space-y-4 bg-background">
74
+ <BuyCoffeeWidget />
75
+ <Button
76
+ variant="ghost"
77
+ className="w-full justify-start cursor-pointer text-muted-foreground hover:text-foreground hover:bg-accent"
78
+ onClick={() => {
79
+ setIsOpen(false);
80
+ setShowSignOutModal(true);
81
+ }}
82
+ >
83
+ <LogOut className="h-5 w-5 mr-3 shrink-0" />
84
+ Sign Out
85
+ </Button>
86
+ </div>
87
+ </SheetContent>
88
+ </Sheet>
89
 
90
  <SignOutModal
91
  isOpen={showSignOutModal}
 
94
  </>
95
  );
96
  }
97
+
components/node-editor/node-editor.tsx CHANGED
@@ -86,7 +86,7 @@ export interface NodeData {
86
  interface NodeEditorProps {
87
  initialNodes?: Node<NodeData>[];
88
  initialEdges?: Edge[];
89
- onSave?: (nodes: Node<NodeData>[], edges: Edge[], options?: { isAutoSave?: boolean }) => void;
90
  isSaving?: boolean;
91
  workflowId?: string;
92
  }
@@ -188,29 +188,11 @@ export function NodeEditor({
188
  return () => window.removeEventListener('keydown', handleKeyDown);
189
  }, [handleUndo, handleRedo, onSave, nodes, edges, toast]);
190
 
191
- // Auto-Save
192
- const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
193
- const [isAutoSaving, setIsAutoSaving] = useState(false);
194
 
195
- useEffect(() => {
196
- if (!onSave) return;
197
-
198
- // Clear previous timeout
199
- if (saveTimeoutRef.current) {
200
- clearTimeout(saveTimeoutRef.current);
201
- }
202
-
203
- // Capture changes for auto-save
204
- setIsAutoSaving(true);
205
- saveTimeoutRef.current = setTimeout(() => {
206
- onSave(nodes, edges, { isAutoSave: true });
207
- setIsAutoSaving(false);
208
- }, 2000); // 2 second debounce
209
-
210
- return () => {
211
- if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
212
- };
213
- }, [nodes, edges, onSave]);
214
 
215
  // Paste node handler - using ref to avoid hoisting issues
216
  const handlePasteNodeRef = useRef<(() => void) | null>(null);
@@ -690,7 +672,7 @@ export function NodeEditor({
690
  <Tooltip>
691
  <TooltipTrigger asChild>
692
  <Button onClick={handleSave} size="icon" variant="outline" disabled={isSaving}>
693
- {(isSaving || isAutoSaving) ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
694
  </Button>
695
  </TooltipTrigger>
696
  <TooltipContent>Save Workflow</TooltipContent>
 
86
  interface NodeEditorProps {
87
  initialNodes?: Node<NodeData>[];
88
  initialEdges?: Edge[];
89
+ onSave?: (nodes: Node<NodeData>[], edges: Edge[]) => void;
90
  isSaving?: boolean;
91
  workflowId?: string;
92
  }
 
188
  return () => window.removeEventListener('keydown', handleKeyDown);
189
  }, [handleUndo, handleRedo, onSave, nodes, edges, toast]);
190
 
191
+ // Auto-Save Removed
192
+ // const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
193
+ // const [isAutoSaving, setIsAutoSaving] = useState(false);
194
 
195
+ // useEffect(() => { ... }) code removed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
 
197
  // Paste node handler - using ref to avoid hoisting issues
198
  const handlePasteNodeRef = useRef<(() => void) | null>(null);
 
672
  <Tooltip>
673
  <TooltipTrigger asChild>
674
  <Button onClick={handleSave} size="icon" variant="outline" disabled={isSaving}>
675
+ {isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
676
  </Button>
677
  </TooltipTrigger>
678
  <TooltipContent>Save Workflow</TooltipContent>
components/settings/social-settings.tsx ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { Button } from "@/components/ui/button";
4
+ import {
5
+ Card,
6
+ CardContent,
7
+ CardDescription,
8
+ CardHeader,
9
+ CardTitle,
10
+ } from "@/components/ui/card";
11
+ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
12
+ import { Share2, AlertCircle, CheckCircle2, RefreshCw, Linkedin } from "lucide-react";
13
+ import { SiFacebook, SiInstagram, SiYoutube } from "@icons-pack/react-simple-icons";
14
+ import Link from "next/link";
15
+ import { useSearchParams } from "next/navigation";
16
+
17
+ interface ConnectedAccount {
18
+ id: string;
19
+ userId: string;
20
+ provider: string; // 'facebook', 'linkedin', etc.
21
+ providerAccountId: string;
22
+ name?: string | null;
23
+ picture?: string | null;
24
+ accessToken?: string | null;
25
+ expiresAt?: Date | null;
26
+ }
27
+
28
+ interface SocialSettingsProps {
29
+ connectedAccounts: ConnectedAccount[];
30
+ }
31
+
32
+ export function SocialSettings({ connectedAccounts }: SocialSettingsProps) {
33
+ const searchParams = useSearchParams();
34
+ const error = searchParams.get("error");
35
+ const success = searchParams.get("success");
36
+
37
+ const fbAccount = connectedAccounts.find((a) => a.provider === "facebook");
38
+ const linkedinAccount = connectedAccounts.find((a) => a.provider === "linkedin");
39
+ const youtubeAccount = connectedAccounts.find((a) => a.provider === "youtube");
40
+
41
+ return (
42
+ <div className="space-y-6">
43
+ {error && (
44
+ <Alert variant="destructive">
45
+ <AlertCircle className="h-4 w-4" />
46
+ <AlertTitle>Error</AlertTitle>
47
+ <AlertDescription>
48
+ {error === "access_denied"
49
+ ? "You denied access. Please try again and accept permissions."
50
+ : error}
51
+ </AlertDescription>
52
+ </Alert>
53
+ )}
54
+
55
+ {success && (
56
+ <Alert className="border-green-500 text-green-600 bg-green-50">
57
+ <CheckCircle2 className="h-4 w-4" />
58
+ <AlertTitle>Success</AlertTitle>
59
+ <AlertDescription>Account connected successfully!</AlertDescription>
60
+ </Alert>
61
+ )}
62
+
63
+ <div className="grid gap-6 md:grid-cols-2">
64
+ {/* Facebook & Instagram */}
65
+ <Card>
66
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
67
+ <CardTitle className="text-xl font-medium">Facebook & Instagram</CardTitle>
68
+ <div className="flex gap-1">
69
+ <SiFacebook className="h-6 w-6 text-[#1877F2]" />
70
+ <SiInstagram className="h-6 w-6 text-[#E4405F]" />
71
+ </div>
72
+ </CardHeader>
73
+ <CardContent>
74
+ {fbAccount ? (
75
+ <div className="flex flex-col gap-4 mt-4">
76
+ <div className="flex items-center gap-3">
77
+ {fbAccount.picture ? (
78
+ // eslint-disable-next-line @next/next/no-img-element
79
+ <img
80
+ src={fbAccount.picture}
81
+ alt={fbAccount.name || "Profile"}
82
+ className="w-10 h-10 rounded-full"
83
+ />
84
+ ) : (
85
+ <div className="w-10 h-10 rounded-full bg-slate-200 flex items-center justify-center">
86
+ <span className="text-lg font-bold text-slate-500">
87
+ {fbAccount.name?.charAt(0)}
88
+ </span>
89
+ </div>
90
+ )}
91
+ <div>
92
+ <p className="font-medium">{fbAccount.name}</p>
93
+ <p className="text-xs text-muted-foreground">
94
+ Connected as {fbAccount.providerAccountId}
95
+ </p>
96
+ </div>
97
+ </div>
98
+ <div className="flex items-center gap-2 text-sm text-green-600 bg-green-50 p-2 rounded border border-green-100">
99
+ <CheckCircle2 className="h-4 w-4" />
100
+ Connected & Active
101
+ </div>
102
+ <Button variant="outline" className="w-full gap-2" asChild>
103
+ <Link href="/api/social/connect/facebook">
104
+ <RefreshCw className="h-4 w-4" /> Reconnect / Refresh
105
+ </Link>
106
+ </Button>
107
+ </div>
108
+ ) : (
109
+ <div className="flex flex-col gap-4 mt-4">
110
+ <p className="text-sm text-muted-foreground">
111
+ Connect to manage Facebook Pages and Instagram Business accounts.
112
+ </p>
113
+ <Button className="w-full bg-[#1877F2] hover:bg-[#1864D9]" asChild>
114
+ <Link href="/api/social/connect/facebook">
115
+ Connect Facebook
116
+ </Link>
117
+ </Button>
118
+ </div>
119
+ )}
120
+ </CardContent>
121
+ </Card>
122
+
123
+ {/* LinkedIn */}
124
+ <Card>
125
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
126
+ <CardTitle className="text-xl font-medium">LinkedIn</CardTitle>
127
+ <Linkedin className="h-6 w-6 text-[#0A66C2] fill-current" />
128
+ </CardHeader>
129
+ <CardContent>
130
+ {linkedinAccount ? (
131
+ <div className="flex flex-col gap-4 mt-4">
132
+ <div className="flex items-center gap-3">
133
+ {linkedinAccount.picture ? (
134
+ // eslint-disable-next-line @next/next/no-img-element
135
+ <img
136
+ src={linkedinAccount.picture}
137
+ alt={linkedinAccount.name || "Profile"}
138
+ className="w-10 h-10 rounded-full"
139
+ />
140
+ ) : (
141
+ <div className="w-10 h-10 rounded-full bg-slate-200 flex items-center justify-center">
142
+ <span className="text-lg font-bold text-slate-500">
143
+ {linkedinAccount.name?.charAt(0)}
144
+ </span>
145
+ </div>
146
+ )}
147
+ <div>
148
+ <p className="font-medium">{linkedinAccount.name}</p>
149
+ <p className="text-xs text-muted-foreground">
150
+ Connected as {linkedinAccount.providerAccountId}
151
+ </p>
152
+ </div>
153
+ </div>
154
+ <div className="flex items-center gap-2 text-sm text-green-600 bg-green-50 p-2 rounded border border-green-100">
155
+ <CheckCircle2 className="h-4 w-4" />
156
+ Connected & Active
157
+ </div>
158
+ <Button variant="outline" className="w-full gap-2" asChild>
159
+ <Link href="/api/social/connect/linkedin">
160
+ <RefreshCw className="h-4 w-4" /> Reconnect / Refresh
161
+ </Link>
162
+ </Button>
163
+ </div>
164
+ ) : (
165
+ <div className="flex flex-col gap-4 mt-4">
166
+ <p className="text-sm text-muted-foreground">
167
+ Connect LinkedIn to create posts and manage your profile.
168
+ </p>
169
+ <Button className="w-full bg-[#0A66C2] hover:bg-[#004182]" asChild>
170
+ <Link href="/api/social/connect/linkedin">
171
+ Connect LinkedIn
172
+ </Link>
173
+ </Button>
174
+ </div>
175
+ )}
176
+ </CardContent>
177
+ </Card>
178
+
179
+ {/* YouTube */}
180
+ <Card>
181
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
182
+ <CardTitle className="text-xl font-medium">YouTube</CardTitle>
183
+ <SiYoutube className="h-6 w-6 text-[#FF0000]" />
184
+ </CardHeader>
185
+ <CardContent>
186
+ {youtubeAccount ? (
187
+ <div className="flex flex-col gap-4 mt-4">
188
+ <div className="flex items-center gap-3">
189
+ {youtubeAccount.picture ? (
190
+ // eslint-disable-next-line @next/next/no-img-element
191
+ <img
192
+ src={youtubeAccount.picture}
193
+ alt={youtubeAccount.name || "Profile"}
194
+ className="w-10 h-10 rounded-full"
195
+ />
196
+ ) : (
197
+ <div className="w-10 h-10 rounded-full bg-slate-200 flex items-center justify-center">
198
+ <span className="text-lg font-bold text-slate-500">
199
+ {youtubeAccount.name?.charAt(0)}
200
+ </span>
201
+ </div>
202
+ )}
203
+ <div>
204
+ <p className="font-medium">{youtubeAccount.name}</p>
205
+ <p className="text-xs text-muted-foreground">
206
+ Connected as {youtubeAccount.providerAccountId}
207
+ </p>
208
+ </div>
209
+ </div>
210
+ <div className="flex items-center gap-2 text-sm text-green-600 bg-green-50 p-2 rounded border border-green-100">
211
+ <CheckCircle2 className="h-4 w-4" />
212
+ Connected & Active
213
+ </div>
214
+ <Button variant="outline" className="w-full gap-2" asChild>
215
+ <Link href="/api/social/connect/youtube">
216
+ <RefreshCw className="h-4 w-4" /> Reconnect / Refresh
217
+ </Link>
218
+ </Button>
219
+ </div>
220
+ ) : (
221
+ <div className="flex flex-col gap-4 mt-4">
222
+ <p className="text-sm text-muted-foreground">
223
+ Connect YouTube to upload videos and view analytics.
224
+ </p>
225
+ <Button className="w-full bg-[#FF0000] hover:bg-[#CC0000]" asChild>
226
+ <Link href="/api/social/connect/youtube">
227
+ Connect YouTube
228
+ </Link>
229
+ </Button>
230
+ </div>
231
+ )}
232
+ </CardContent>
233
+ </Card>
234
+ </div>
235
+ </div>
236
+ );
237
+ }
components/ui/calendar.tsx ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { ChevronLeft, ChevronRight } from "lucide-react"
5
+ import { DayPicker } from "react-day-picker"
6
+
7
+ import { cn } from "@/lib/utils"
8
+ import { buttonVariants } from "@/components/ui/button"
9
+
10
+ export type CalendarProps = React.ComponentProps<typeof DayPicker>
11
+
12
+ function Calendar({
13
+ className,
14
+ classNames,
15
+ showOutsideDays = true,
16
+ ...props
17
+ }: CalendarProps) {
18
+ return (
19
+ <DayPicker
20
+ showOutsideDays={showOutsideDays}
21
+ className={cn("p-3", className)}
22
+ classNames={{
23
+ months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
24
+ month: "space-y-4",
25
+ caption: "flex justify-center pt-1 relative items-center",
26
+ caption_label: "text-sm font-medium",
27
+ nav: "space-x-1 flex items-center",
28
+ nav_button: cn(
29
+ buttonVariants({ variant: "outline" }),
30
+ "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
31
+ ),
32
+ nav_button_previous: "absolute left-1",
33
+ nav_button_next: "absolute right-1",
34
+ table: "w-full border-collapse space-y-1",
35
+ head_row: "flex",
36
+ head_cell:
37
+ "text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
38
+ row: "flex w-full mt-2",
39
+ cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
40
+ day: cn(
41
+ buttonVariants({ variant: "ghost" }),
42
+ "h-9 w-9 p-0 font-normal aria-selected:opacity-100"
43
+ ),
44
+ day_range_end: "day-range-end",
45
+ day_selected:
46
+ "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
47
+ day_today: "bg-accent text-accent-foreground",
48
+ day_outside:
49
+ "day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
50
+ day_disabled: "text-muted-foreground opacity-50",
51
+ day_range_middle:
52
+ "aria-selected:bg-accent aria-selected:text-accent-foreground",
53
+ day_hidden: "invisible",
54
+ ...classNames,
55
+ }}
56
+ components={{
57
+ IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" {...props} />,
58
+ IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" {...props} />,
59
+ }}
60
+ {...props}
61
+ />
62
+ )
63
+ }
64
+ Calendar.displayName = "Calendar"
65
+
66
+ export { Calendar }
components/ui/skeleton.tsx ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { cn } from "@/lib/utils"
2
+
3
+ function Skeleton({
4
+ className,
5
+ ...props
6
+ }: React.HTMLAttributes<HTMLDivElement>) {
7
+ return (
8
+ <div
9
+ className={cn("animate-pulse rounded-md bg-muted", className)}
10
+ {...props}
11
+ />
12
+ )
13
+ }
14
+
15
+ export { Skeleton }
db/schema/index.ts CHANGED
@@ -237,6 +237,10 @@ export const socialPosts = pgTable("social_posts", {
237
  userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
238
  connectedAccountId: text("connected_account_id").references(() => connectedAccounts.id, { onDelete: "set null" }),
239
  content: text("content"),
 
 
 
 
240
  mediaUrls: jsonb("media_urls").$type<string[]>(),
241
  scheduledAt: timestamp("scheduled_at"),
242
  status: varchar("status", { length: 20 }).default("draft").notNull(), // 'draft', 'scheduled', 'published', 'failed'
 
237
  userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
238
  connectedAccountId: text("connected_account_id").references(() => connectedAccounts.id, { onDelete: "set null" }),
239
  content: text("content"),
240
+ title: text("title"),
241
+ thumbnailUrl: text("thumbnail_url"),
242
+ tags: jsonb("tags").$type<string[]>(),
243
+ category: text("category"),
244
  mediaUrls: jsonb("media_urls").$type<string[]>(),
245
  scheduledAt: timestamp("scheduled_at"),
246
  status: varchar("status", { length: 20 }).default("draft").notNull(), // 'draft', 'scheduled', 'published', 'failed'
lib/gemini.ts CHANGED
@@ -6,10 +6,13 @@ import { db } from "@/db";
6
  import { users } from "@/db/schema";
7
  import { eq } from "drizzle-orm";
8
 
9
- async function getEffectiveApiKey(providedKey?: string): Promise<string | undefined> {
 
 
 
10
  if (providedKey) return providedKey;
11
- if (process.env.GEMINI_API_KEY) return process.env.GEMINI_API_KEY;
12
 
 
13
  try {
14
  const session = await auth();
15
  if (session?.user?.id) {
@@ -22,6 +25,10 @@ async function getEffectiveApiKey(providedKey?: string): Promise<string | undefi
22
  } catch (error) {
23
  console.warn("Failed to fetch Gemini API key from DB:", error);
24
  }
 
 
 
 
25
  return undefined;
26
  }
27
 
@@ -31,16 +38,15 @@ export async function generateEmailTemplate(
31
  apiKey?: string
32
  ): Promise<{ subject: string; body: string }> {
33
  try {
34
- const key = apiKey || process.env.GEMINI_API_KEY;
35
- if (!key) throw new Error("No Gemini API key provided");
36
 
37
  const client = new GoogleGenerativeAI(key);
38
 
39
  // Models to try in order of preference
40
- const models = ["gemini-3-flash-preview", "gemini-2.0-flash"]; /* "gemini-2.0-flash-exp" is also common, but sticking to user request */
41
  let lastError;
42
 
43
- for (const modelName of models) {
44
  try {
45
  const model = client.getGenerativeModel({ model: modelName });
46
 
@@ -112,16 +118,15 @@ export async function evaluateCondition(
112
  */
113
  export async function generateAIContent(prompt: string, apiKey?: string): Promise<string> {
114
  try {
115
- const key = apiKey || process.env.GEMINI_API_KEY;
116
  if (!key) {
117
  throw new Error("Gemini API key not configured");
118
  }
119
 
120
  const genAI = new GoogleGenerativeAI(key);
121
- const models = ["gemini-3-flash-preview", "gemini-2.0-flash"];
122
  let lastError;
123
 
124
- for (const modelName of models) {
125
  try {
126
  const model = genAI.getGenerativeModel({ model: modelName });
127
 
@@ -152,17 +157,16 @@ export async function generateWorkflowFromPrompt(
152
  apiKey?: string
153
  ): Promise<{ nodes: Node[]; edges: Edge[] }> {
154
  try {
155
- const key = apiKey || process.env.GEMINI_API_KEY;
156
  if (!key) {
157
  throw new Error("Gemini API key not configured");
158
  }
159
 
160
  const genAI = new GoogleGenerativeAI(key);
161
  // Models to try in order
162
- const models = ["gemini-3-flash-preview", "gemini-2.0-flash", "gemini-1.5-flash", "gemini-1.5-pro"];
163
  let lastError;
164
 
165
- for (const modelName of models) {
166
  try {
167
  const model = genAI.getGenerativeModel({ model: modelName });
168
 
 
6
  import { users } from "@/db/schema";
7
  import { eq } from "drizzle-orm";
8
 
9
+ // Standardize models across the app (newest to oldest/most stable)
10
+ export const GEMINI_MODELS = ["gemini-3-flash-preview", "gemini-2.0-flash", "gemini-1.5-flash", "gemini-1.5-pro"];
11
+
12
+ export async function getEffectiveApiKey(providedKey?: string): Promise<string | undefined> {
13
  if (providedKey) return providedKey;
 
14
 
15
+ // Try fetching from DB first (User preference overrides global env)
16
  try {
17
  const session = await auth();
18
  if (session?.user?.id) {
 
25
  } catch (error) {
26
  console.warn("Failed to fetch Gemini API key from DB:", error);
27
  }
28
+
29
+ // Fallback to environment variable
30
+ if (process.env.GEMINI_API_KEY) return process.env.GEMINI_API_KEY;
31
+
32
  return undefined;
33
  }
34
 
 
38
  apiKey?: string
39
  ): Promise<{ subject: string; body: string }> {
40
  try {
41
+ const key = await getEffectiveApiKey(apiKey);
42
+ if (!key) throw new Error("No Gemini API key provided. Please configure it in settings or .env");
43
 
44
  const client = new GoogleGenerativeAI(key);
45
 
46
  // Models to try in order of preference
 
47
  let lastError;
48
 
49
+ for (const modelName of GEMINI_MODELS) {
50
  try {
51
  const model = client.getGenerativeModel({ model: modelName });
52
 
 
118
  */
119
  export async function generateAIContent(prompt: string, apiKey?: string): Promise<string> {
120
  try {
121
+ const key = await getEffectiveApiKey(apiKey);
122
  if (!key) {
123
  throw new Error("Gemini API key not configured");
124
  }
125
 
126
  const genAI = new GoogleGenerativeAI(key);
 
127
  let lastError;
128
 
129
+ for (const modelName of GEMINI_MODELS) {
130
  try {
131
  const model = genAI.getGenerativeModel({ model: modelName });
132
 
 
157
  apiKey?: string
158
  ): Promise<{ nodes: Node[]; edges: Edge[] }> {
159
  try {
160
+ const key = await getEffectiveApiKey(apiKey);
161
  if (!key) {
162
  throw new Error("Gemini API key not configured");
163
  }
164
 
165
  const genAI = new GoogleGenerativeAI(key);
166
  // Models to try in order
 
167
  let lastError;
168
 
169
+ for (const modelName of GEMINI_MODELS) {
170
  try {
171
  const model = genAI.getGenerativeModel({ model: modelName });
172
 
lib/social/publisher.ts CHANGED
@@ -1,113 +1,270 @@
 
 
 
 
1
 
2
- interface PublishParams {
3
- accessToken: string;
4
- providerAccountId: string; // Page ID for FB, IG Business User ID for IG
5
- content?: string;
6
- mediaUrls?: string[];
 
7
  }
8
 
9
- export async function publishToFacebook({ accessToken, providerAccountId, content, mediaUrls }: PublishParams) {
10
- // For now, let's support text-only or single image for MVP simplification
11
- // Multi-photo posts on Graph API require a staged upload flow (unpublished media -> attached to post)
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
- const pageId = providerAccountId;
14
- let endpoint = `https://graph.facebook.com/v19.0/${pageId}/feed`;
15
- const params = new URLSearchParams({ access_token: accessToken });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
- if (content) params.append("message", content);
 
 
 
18
 
19
- if (mediaUrls && mediaUrls.length > 0) {
20
- // If photos, use /photos endpoint for single photo
21
- // TODO: Support multi-photo
22
- endpoint = `https://graph.facebook.com/v19.0/${pageId}/photos`;
23
- params.append("url", mediaUrls[0]);
24
- if (content) params.append("caption", content); // FB photos use 'caption' not 'message'
25
- }
26
 
27
- const res = await fetch(`${endpoint}?${params.toString()}`, { method: "POST" });
28
- const data = await res.json();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
- if (data.error) {
31
- throw new Error(data.error.message);
32
- }
 
33
 
34
- return { id: data.id || data.post_id };
35
- }
 
 
36
 
 
 
 
 
 
 
37
 
38
- export async function publishToInstagram({ accessToken, providerAccountId, content, mediaUrls }: PublishParams) {
39
- // Instagram requires: 1. Create Container, 2. Publish Container
40
- const igUserId = providerAccountId;
 
 
 
 
 
 
41
 
42
- if (!mediaUrls || mediaUrls.length === 0) {
43
- throw new Error("Instagram posts require at least one image/video.");
44
- }
 
 
 
 
 
 
 
 
 
 
 
45
 
46
- const imageUrl = mediaUrls[0]; // Single image for MVP
 
 
 
47
 
48
- // Step 1: Create Container
49
- // https://graph.facebook.com/v19.0/{ig-user-id}/media
50
- const containerParams = new URLSearchParams({
51
- access_token: accessToken,
52
- image_url: imageUrl,
53
- });
54
- if (content) containerParams.append("caption", content);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
 
56
- const containerRes = await fetch(`https://graph.facebook.com/v19.0/${igUserId}/media?${containerParams.toString()}`, { method: "POST" });
57
- const containerData = await containerRes.json();
 
 
 
 
 
58
 
59
- if (containerData.error) {
60
- throw new Error(`IG Container Error: ${containerData.error.message}`);
61
- }
62
 
63
- const containerId = containerData.id;
 
 
 
 
 
 
 
 
 
 
64
 
65
- // Step 2: Publish Container
66
- // https://graph.facebook.com/v19.0/{ig-user-id}/media_publish
67
- const publishParams = new URLSearchParams({
68
- access_token: accessToken,
69
- creation_id: containerId
70
- });
 
 
 
 
 
 
 
 
 
 
 
71
 
72
- const publishRes = await fetch(`https://graph.facebook.com/v19.0/${igUserId}/media_publish?${publishParams.toString()}`, { method: "POST" });
73
- const publishData = await publishRes.json();
 
 
 
 
 
 
 
 
74
 
75
- if (publishData.error) {
76
- throw new Error(`IG Publish Error: ${publishData.error.message}`);
77
- }
 
 
 
 
 
 
78
 
 
 
 
 
79
 
80
- return { id: publishData.id };
81
- }
 
 
 
82
 
83
- export async function replyToComment(commentId: string, message: string, accessToken: string) {
84
- const res = await fetch(`https://graph.facebook.com/v19.0/${commentId}/comments`, {
85
- method: "POST",
86
- headers: { "Content-Type": "application/json" },
87
- body: JSON.stringify({
88
- message,
89
- access_token: accessToken
90
- })
91
- });
92
- const data = await res.json();
93
- if (data.error) throw new Error(data.error.message);
94
- return data;
95
- }
96
 
97
- export async function sendPrivateMessage(recipientId: string, message: string, accessToken: string) {
98
- // For Pages messaging users (Messenger Platform)
99
- // NOTE: This requires 'pages_messaging' permission
100
- const res = await fetch(`https://graph.facebook.com/v19.0/me/messages`, {
101
- method: "POST",
102
- headers: { "Content-Type": "application/json" },
103
- body: JSON.stringify({
104
- recipient: { id: recipientId },
105
- message: { text: message },
106
- access_token: accessToken
107
- })
108
- });
109
- const data = await res.json();
110
- if (data.error) throw new Error(data.error.message);
111
- return data;
112
- }
 
 
 
 
 
 
 
 
 
113
 
 
 
 
 
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { google } from "googleapis";
4
+ import { Readable } from "stream";
5
 
6
+ export interface SocialPostPayload {
7
+ content: string;
8
+ mediaUrl?: string; // Relative path like "/uploads/xyz.jpg"
9
+ accessToken: string;
10
+ providerAccountId: string;
11
+ refreshToken?: string; // Needed for YouTube
12
  }
13
 
14
+ // Helper to get file stream
15
+ const getFileStream = (mediaUrl: string) => {
16
+ // Assume mediaUrl is relative to public (e.g. /uploads/file.jpg)
17
+ // Remove query params if any
18
+ const cleanPath = mediaUrl.split('?')[0];
19
+ const filePath = path.join(process.cwd(), "public", cleanPath);
20
+ if (!fs.existsSync(filePath)) {
21
+ throw new Error(`File not found at ${filePath}`);
22
+ }
23
+ return {
24
+ stream: fs.createReadStream(filePath),
25
+ size: fs.statSync(filePath).size,
26
+ path: filePath,
27
+ mimeType: cleanPath.endsWith(".mp4") ? "video/mp4" : cleanPath.endsWith(".png") ? "image/png" : "image/jpeg"
28
+ };
29
+ };
30
 
31
+ export const socialPublisher = {
32
+ publishToFacebook: async (payload: SocialPostPayload) => {
33
+ const { content, mediaUrl, accessToken, providerAccountId } = payload;
34
+
35
+ const params: any = new FormData();
36
+ params.append("access_token", accessToken);
37
+
38
+ let url = `https://graph.facebook.com/v21.0/${providerAccountId}/feed`;
39
+
40
+ if (mediaUrl) {
41
+ const file = getFileStream(mediaUrl);
42
+ const isVideo = file.mimeType.startsWith("video");
43
+
44
+ if (isVideo) {
45
+ url = `https://graph.facebook.com/v21.0/${providerAccountId}/videos`;
46
+ params.append("description", content);
47
+
48
+ params.append("source", new Blob([fs.readFileSync(file.path)]), path.basename(file.path));
49
+ } else {
50
+ url = `https://graph.facebook.com/v21.0/${providerAccountId}/photos`;
51
+ params.append("message", content);
52
+
53
+ params.append("source", new Blob([fs.readFileSync(file.path)]), path.basename(file.path));
54
+ }
55
+ } else {
56
+ params.append("message", content);
57
+ }
58
 
59
+ const res = await fetch(url, {
60
+ method: "POST",
61
+ body: params,
62
+ });
63
 
64
+ const data = await res.json();
65
+ if (data.error) throw new Error(data.error.message);
66
+ return data.id;
67
+ },
 
 
 
68
 
69
+ publishToInstagram: async (payload: SocialPostPayload) => {
70
+ // IG still requires public URL for 'image_url' or 'video_url' in Content Publishing API usually.
71
+ // However, for strict binary upload, we might need to use the graph API slightly differently or rely on a public URL.
72
+ // Since we are running locally, "localhost" urls won't work for IG to download.
73
+ // BUT, we can use the "Upload API" if we were using the Instagram Graph API for Stories etc, but for Feed it primarily asks for URL.
74
+
75
+ // CRITICAL LIMITATION: Instagram Graph API *requires* the image/video to be on a public URL. It does NOT support binary upload for feed posts easily like FB.
76
+ // Workaround: We must warn the user if on localhost.
77
+ // OR: If we really want "real business suite", we'd upload to a temporary Image hosting service or S3.
78
+ // For this task, we will try to use the binary upload endpoint valid for *Stories* or *Reels* if applicable, but for generic posts it's tricky.
79
+ // ACTUALLY: The "Container" endpoint only accepts 'image_url'.
80
+
81
+ // Strategy: Throw error if URL is localhost, prompt to use ngrok or deploy.
82
+ const { content, mediaUrl, accessToken, providerAccountId } = payload;
83
+
84
+ if (!mediaUrl) throw new Error("Instagram posts require media");
85
+
86
+ // Check for localhost
87
+ // Use process.env.NEXT_PUBLIC_APP_URL
88
+ const appUrl = process.env.NEXT_PUBLIC_APP_URL || "";
89
+ if (appUrl.includes("localhost") || appUrl.includes("127.0.0.1")) {
90
+ throw new Error("Instagram Direct Publishing requires a publically accessible Media URL. Localhost is not supported by Meta. Please deploy the app or use ngrok.");
91
+ }
92
 
93
+ // Logic remains same as URL based for IG because it enforces it.
94
+ // ... (previous logic) ...
95
+ const containerUrl = `https://graph.facebook.com/v21.0/${providerAccountId}/media`;
96
+ const fullUrl = mediaUrl.startsWith("http") ? mediaUrl : `${appUrl}${mediaUrl}`;
97
 
98
+ const containerParams: any = {
99
+ access_token: accessToken,
100
+ caption: content,
101
+ };
102
 
103
+ if (mediaUrl.match(/\.(jpg|jpeg|png)$/i)) {
104
+ containerParams.image_url = fullUrl;
105
+ } else if (mediaUrl.match(/\.(mp4|mov)$/i)) {
106
+ containerParams.media_type = "VIDEO";
107
+ containerParams.video_url = fullUrl;
108
+ }
109
 
110
+ const containerRes = await fetch(containerUrl, {
111
+ method: "POST",
112
+ headers: { "Content-Type": "application/json" },
113
+ body: JSON.stringify(containerParams),
114
+ });
115
+ const containerData = await containerRes.json();
116
+ if (containerData.error) throw new Error(containerData.error.message);
117
+
118
+ const creationId = containerData.id;
119
 
120
+ // Step 2: Publish
121
+ const publishUrl = `https://graph.facebook.com/v21.0/${providerAccountId}/media_publish`;
122
+ const publishRes = await fetch(publishUrl, {
123
+ method: "POST",
124
+ headers: { "Content-Type": "application/json" },
125
+ body: JSON.stringify({
126
+ creation_id: creationId,
127
+ access_token: accessToken
128
+ }),
129
+ });
130
+ const publishData = await publishRes.json();
131
+ if (publishData.error) throw new Error(publishData.error.message);
132
+ return publishData.id;
133
+ },
134
 
135
+ publishToLinkedin: async (payload: SocialPostPayload) => {
136
+ const { content, mediaUrl, accessToken, providerAccountId } = payload;
137
+
138
+ let asset = null;
139
 
140
+ if (mediaUrl) {
141
+ const file = getFileStream(mediaUrl);
142
+
143
+ // 1. Register Upload
144
+ const registerUrl = "https://api.linkedin.com/v2/assets?action=registerUpload";
145
+ const registerBody = {
146
+ registerUploadRequest: {
147
+ recipes: [
148
+ "urn:li:digitalmediaRecipe:feedshare-image" // Simplified for image
149
+ ],
150
+ owner: `urn:li:person:${providerAccountId}`,
151
+ serviceRelationships: [
152
+ {
153
+ relationshipType: "OWNER",
154
+ identifier: "urn:li:userGeneratedContent"
155
+ }
156
+ ]
157
+ }
158
+ };
159
+ // Note: Video recipe is 'urn:li:digitalmediaRecipe:feedshare-video'
160
+ if (file.mimeType.startsWith("video")) {
161
+ registerBody.registerUploadRequest.recipes = ["urn:li:digitalmediaRecipe:feedshare-video"];
162
+ }
163
 
164
+ const regRes = await fetch(registerUrl, {
165
+ method: "POST",
166
+ headers: { "Authorization": `Bearer ${accessToken}`, "Content-Type": "application/json" },
167
+ body: JSON.stringify(registerBody)
168
+ });
169
+ const regData = await regRes.json();
170
+ if (regData.status && regData.status !== 200) throw new Error("LinkedIn Register Failed: " + JSON.stringify(regData));
171
 
172
+ const uploadUrl = regData.value.uploadMechanism["com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest"].uploadUrl;
173
+ asset = regData.value.asset;
 
174
 
175
+ // 2. Upload Binary
176
+ const uploadRes = await fetch(uploadUrl, {
177
+ method: "PUT",
178
+ headers: { "Authorization": `Bearer ${accessToken}`, "Content-Type": "application/octet-stream" },
179
+ body: fs.readFileSync(file.path)
180
+ });
181
+
182
+ if (uploadRes.status !== 201 && uploadRes.status !== 200) {
183
+ throw new Error("LinkedIn Binary Upload Failed");
184
+ }
185
+ }
186
 
187
+ // 3. Create Post
188
+ const url = "https://api.linkedin.com/v2/ugcPosts";
189
+ const body: any = {
190
+ author: `urn:li:person:${providerAccountId}`,
191
+ lifecycleState: "PUBLISHED",
192
+ specificContent: {
193
+ "com.linkedin.ugc.ShareContent": {
194
+ shareCommentary: {
195
+ text: content
196
+ },
197
+ shareMediaCategory: asset ? (mediaUrl?.match(/\.mp4$/) ? "VIDEO" : "IMAGE") : "NONE",
198
+ }
199
+ },
200
+ visibility: {
201
+ "com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
202
+ }
203
+ };
204
 
205
+ if (asset) {
206
+ body.specificContent["com.linkedin.ugc.ShareContent"].media = [
207
+ {
208
+ status: "READY",
209
+ description: { text: "Center stage!" },
210
+ media: asset,
211
+ title: { text: "Uploaded Media" }
212
+ }
213
+ ];
214
+ }
215
 
216
+ const res = await fetch(url, {
217
+ method: "POST",
218
+ headers: {
219
+ "Authorization": `Bearer ${accessToken}`,
220
+ "Content-Type": "application/json",
221
+ "X-Restli-Protocol-Version": "2.0.0"
222
+ },
223
+ body: JSON.stringify(body)
224
+ });
225
 
226
+ const data = await res.json();
227
+ if (data.status && data.status !== 201) throw new Error(data.message || "Linkedin Post Failed");
228
+ return data.id;
229
+ },
230
 
231
+ publishToYoutube: async (payload: SocialPostPayload) => {
232
+ const { content, mediaUrl, accessToken, refreshToken } = payload;
233
+
234
+ if (!mediaUrl) throw new Error("YouTube requires a video file.");
235
+ const file = getFileStream(mediaUrl);
236
 
237
+ const oauth2Client = new google.auth.OAuth2(
238
+ process.env.GOOGLE_CLIENT_ID,
239
+ process.env.GOOGLE_CLIENT_SECRET
240
+ );
 
 
 
 
 
 
 
 
 
241
 
242
+ oauth2Client.setCredentials({
243
+ access_token: accessToken,
244
+ refresh_token: refreshToken
245
+ });
246
+
247
+ const youtube = google.youtube({
248
+ version: 'v3',
249
+ auth: oauth2Client
250
+ });
251
+
252
+ const res = await youtube.videos.insert({
253
+ part: ['snippet', 'status'],
254
+ requestBody: {
255
+ snippet: {
256
+ title: content.substring(0, 100), // Title is limited
257
+ description: content,
258
+ },
259
+ status: {
260
+ privacyStatus: 'public' // or private/unlisted
261
+ }
262
+ },
263
+ media: {
264
+ body: fs.createReadStream(file.path)
265
+ }
266
+ });
267
 
268
+ return res.data.id;
269
+ }
270
+ };
package.json CHANGED
@@ -28,6 +28,7 @@
28
  "@dnd-kit/utilities": "^3.2.2",
29
  "@google/generative-ai": "^0.21.0",
30
  "@hookform/resolvers": "^3.9.1",
 
31
  "@neondatabase/serverless": "^0.10.3",
32
  "@paralleldrive/cuid2": "^3.0.6",
33
  "@radix-ui/react-alert-dialog": "^1.1.15",
@@ -55,6 +56,7 @@
55
  "clsx": "^2.1.1",
56
  "cmdk": "^1.1.1",
57
  "cron-parser": "^5.5.0",
 
58
  "dotenv": "^17.2.3",
59
  "drizzle-orm": "^0.37.0",
60
  "framer-motion": "^12.0.6",
@@ -70,6 +72,7 @@
70
  "puppeteer": "^24.35.0",
71
  "react": "19.2.3",
72
  "react-copy-to-clipboard": "^5.1.0",
 
73
  "react-dom": "19.2.3",
74
  "react-hook-form": "^7.54.2",
75
  "reactflow": "^11.11.4",
 
28
  "@dnd-kit/utilities": "^3.2.2",
29
  "@google/generative-ai": "^0.21.0",
30
  "@hookform/resolvers": "^3.9.1",
31
+ "@icons-pack/react-simple-icons": "^13.8.0",
32
  "@neondatabase/serverless": "^0.10.3",
33
  "@paralleldrive/cuid2": "^3.0.6",
34
  "@radix-ui/react-alert-dialog": "^1.1.15",
 
56
  "clsx": "^2.1.1",
57
  "cmdk": "^1.1.1",
58
  "cron-parser": "^5.5.0",
59
+ "date-fns": "^4.1.0",
60
  "dotenv": "^17.2.3",
61
  "drizzle-orm": "^0.37.0",
62
  "framer-motion": "^12.0.6",
 
72
  "puppeteer": "^24.35.0",
73
  "react": "19.2.3",
74
  "react-copy-to-clipboard": "^5.1.0",
75
+ "react-day-picker": "^8.10.1",
76
  "react-dom": "19.2.3",
77
  "react-hook-form": "^7.54.2",
78
  "reactflow": "^11.11.4",
pnpm-lock.yaml CHANGED
@@ -23,6 +23,9 @@ importers:
23
  '@hookform/resolvers':
24
  specifier: ^3.9.1
25
  version: 3.10.0(react-hook-form@7.71.1(react@19.2.3))
 
 
 
26
  '@neondatabase/serverless':
27
  specifier: ^0.10.3
28
  version: 0.10.4
@@ -104,6 +107,9 @@ importers:
104
  cron-parser:
105
  specifier: ^5.5.0
106
  version: 5.5.0
 
 
 
107
  dotenv:
108
  specifier: ^17.2.3
109
  version: 17.2.3
@@ -149,6 +155,9 @@ importers:
149
  react-copy-to-clipboard:
150
  specifier: ^5.1.0
151
  version: 5.1.0(react@19.2.3)
 
 
 
152
  react-dom:
153
  specifier: 19.2.3
154
  version: 19.2.3(react@19.2.3)
@@ -1023,6 +1032,11 @@ packages:
1023
  resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
1024
  engines: {node: '>=18.18'}
1025
 
 
 
 
 
 
1026
  '@img/colour@1.0.0':
1027
  resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==}
1028
  engines: {node: '>=18'}
@@ -3555,6 +3569,9 @@ packages:
3555
  resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
3556
  engines: {node: '>= 0.4'}
3557
 
 
 
 
3558
  debug@3.2.7:
3559
  resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
3560
  peerDependencies:
@@ -5437,6 +5454,12 @@ packages:
5437
  peerDependencies:
5438
  react: ^15.3.0 || 16 || 17 || 18
5439
 
 
 
 
 
 
 
5440
  react-dom@19.2.3:
5441
  resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==}
5442
  peerDependencies:
@@ -6913,6 +6936,10 @@ snapshots:
6913
 
6914
  '@humanwhocodes/retry@0.4.3': {}
6915
 
 
 
 
 
6916
  '@img/colour@1.0.0':
6917
  optional: true
6918
 
@@ -9741,6 +9768,8 @@ snapshots:
9741
  es-errors: 1.3.0
9742
  is-data-view: 1.0.2
9743
 
 
 
9744
  debug@3.2.7:
9745
  dependencies:
9746
  ms: 2.1.3
@@ -11986,6 +12015,11 @@ snapshots:
11986
  prop-types: 15.8.1
11987
  react: 19.2.3
11988
 
 
 
 
 
 
11989
  react-dom@19.2.3(react@19.2.3):
11990
  dependencies:
11991
  react: 19.2.3
 
23
  '@hookform/resolvers':
24
  specifier: ^3.9.1
25
  version: 3.10.0(react-hook-form@7.71.1(react@19.2.3))
26
+ '@icons-pack/react-simple-icons':
27
+ specifier: ^13.8.0
28
+ version: 13.8.0(react@19.2.3)
29
  '@neondatabase/serverless':
30
  specifier: ^0.10.3
31
  version: 0.10.4
 
107
  cron-parser:
108
  specifier: ^5.5.0
109
  version: 5.5.0
110
+ date-fns:
111
+ specifier: ^4.1.0
112
+ version: 4.1.0
113
  dotenv:
114
  specifier: ^17.2.3
115
  version: 17.2.3
 
155
  react-copy-to-clipboard:
156
  specifier: ^5.1.0
157
  version: 5.1.0(react@19.2.3)
158
+ react-day-picker:
159
+ specifier: ^8.10.1
160
+ version: 8.10.1(date-fns@4.1.0)(react@19.2.3)
161
  react-dom:
162
  specifier: 19.2.3
163
  version: 19.2.3(react@19.2.3)
 
1032
  resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
1033
  engines: {node: '>=18.18'}
1034
 
1035
+ '@icons-pack/react-simple-icons@13.8.0':
1036
+ resolution: {integrity: sha512-iZrhL1fSklfCCVn68IYHaAoKfcby3RakUTn2tRPyHBkhr2tkYqeQbjJWf+NizIYBzKBn2IarDJXmTdXd6CuEfw==}
1037
+ peerDependencies:
1038
+ react: ^16.13 || ^17 || ^18 || ^19
1039
+
1040
  '@img/colour@1.0.0':
1041
  resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==}
1042
  engines: {node: '>=18'}
 
3569
  resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
3570
  engines: {node: '>= 0.4'}
3571
 
3572
+ date-fns@4.1.0:
3573
+ resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
3574
+
3575
  debug@3.2.7:
3576
  resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
3577
  peerDependencies:
 
5454
  peerDependencies:
5455
  react: ^15.3.0 || 16 || 17 || 18
5456
 
5457
+ react-day-picker@8.10.1:
5458
+ resolution: {integrity: sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==}
5459
+ peerDependencies:
5460
+ date-fns: ^2.28.0 || ^3.0.0
5461
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0
5462
+
5463
  react-dom@19.2.3:
5464
  resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==}
5465
  peerDependencies:
 
6936
 
6937
  '@humanwhocodes/retry@0.4.3': {}
6938
 
6939
+ '@icons-pack/react-simple-icons@13.8.0(react@19.2.3)':
6940
+ dependencies:
6941
+ react: 19.2.3
6942
+
6943
  '@img/colour@1.0.0':
6944
  optional: true
6945
 
 
9768
  es-errors: 1.3.0
9769
  is-data-view: 1.0.2
9770
 
9771
+ date-fns@4.1.0: {}
9772
+
9773
  debug@3.2.7:
9774
  dependencies:
9775
  ms: 2.1.3
 
12015
  prop-types: 15.8.1
12016
  react: 19.2.3
12017
 
12018
+ react-day-picker@8.10.1(date-fns@4.1.0)(react@19.2.3):
12019
+ dependencies:
12020
+ date-fns: 4.1.0
12021
+ react: 19.2.3
12022
+
12023
  react-dom@19.2.3(react@19.2.3):
12024
  dependencies:
12025
  react: 19.2.3
public/uploads/N837gAyXXi6aH6KgcVfPv.ico ADDED
types/index.ts CHANGED
@@ -121,3 +121,18 @@ export interface WorkflowExecutionContext {
121
  userId: string;
122
  workflowId: string;
123
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  userId: string;
122
  workflowId: string;
123
  }
124
+
125
+ export interface ConnectedAccount {
126
+ id: string;
127
+ userId: string;
128
+ provider: string;
129
+ providerAccountId: string;
130
+ accessToken?: string | null;
131
+ refreshToken?: string | null;
132
+ expiresAt?: Date | null;
133
+ name?: string | null;
134
+ email?: string | null;
135
+ metadata?: Record<string, unknown> | null;
136
+ createdAt: Date;
137
+ updatedAt: Date;
138
+ }