add social connect option
Browse files- app/api/ai/generate-content/route.ts +28 -0
- app/api/settings/route.ts +7 -1
- app/api/social/analytics/route.ts +142 -0
- app/api/social/callback/[provider]/route.ts +138 -1
- app/api/social/connect/[provider]/route.ts +43 -10
- app/api/social/posts/create/route.ts +4 -3
- app/api/social/posts/route.ts +169 -0
- app/api/social/youtube/playlists/route.ts +118 -0
- app/api/upload/route.ts +40 -0
- app/dashboard/businesses/page.tsx +2 -1
- app/dashboard/page.tsx +87 -430
- app/dashboard/scraper/page.tsx +226 -0
- app/dashboard/settings/page.tsx +45 -11
- app/dashboard/social/automations/page.tsx +7 -1
- app/dashboard/social/page.tsx +241 -121
- app/dashboard/social/posts/new/page.tsx +677 -22
- app/dashboard/social/posts/new/post-creator-form.tsx +4 -2
- app/dashboard/workflows/builder/[id]/page.tsx +9 -21
- components/dashboard/business-table.tsx +53 -2
- components/dashboard/sidebar.tsx +2 -0
- components/dashboard/user-nav.tsx +1 -1
- components/mobile-nav.tsx +59 -86
- components/node-editor/node-editor.tsx +6 -24
- components/settings/social-settings.tsx +237 -0
- components/ui/calendar.tsx +66 -0
- components/ui/skeleton.tsx +15 -0
- db/schema/index.ts +4 -0
- lib/gemini.ts +16 -12
- lib/social/publisher.ts +245 -88
- package.json +3 -0
- pnpm-lock.yaml +34 -0
- public/uploads/N837gAyXXi6aH6KgcVfPv.ico +0 -0
- types/index.ts +15 -0
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/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 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 {
|
|
|
|
| 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
|
| 4 |
import { StatCard } from "@/components/dashboard/stat-card";
|
| 5 |
import { BusinessTable } from "@/components/dashboard/business-table";
|
| 6 |
-
import {
|
| 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: () => <
|
| 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 {
|
| 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
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
<
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
<
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 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 |
-
|
| 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 |
-
<
|
| 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 |
-
|
| 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 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
<CardTitle>Email Performance (Last 7 Days)</CardTitle>
|
| 471 |
-
</CardHeader>
|
| 472 |
-
<CardContent>
|
| 473 |
<EmailChart data={chartData} loading={loadingStats} />
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 477 |
|
| 478 |
-
|
| 479 |
-
|
| 480 |
<CardHeader className="flex flex-row items-center justify-between">
|
| 481 |
-
<CardTitle>Recent Leads
|
| 482 |
-
<Button variant="
|
| 483 |
-
<
|
| 484 |
</Button>
|
| 485 |
-
|
| 486 |
-
|
| 487 |
<BusinessTable
|
| 488 |
-
businesses={businesses.slice(0,
|
| 489 |
-
|
| 490 |
-
|
|
|
|
| 491 |
/>
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 655 |
-
|
| 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 "Social Connections" above.
|
| 691 |
+
Used for extracting data from LinkedIn profiles directly. Use F12 > Application > 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 |
-
|
| 2 |
-
|
| 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 {
|
| 9 |
-
import {
|
|
|
|
| 10 |
import Link from "next/link";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
-
export default
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
const session = await auth();
|
| 18 |
-
if (!session?.user?.id) redirect("/auth/signin");
|
| 19 |
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
-
|
| 29 |
-
|
| 30 |
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
return (
|
| 36 |
<div className="flex flex-col gap-6 p-6">
|
| 37 |
<div className="flex flex-col gap-2">
|
| 38 |
-
<
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
</div>
|
| 43 |
|
| 44 |
-
|
| 45 |
-
<
|
| 46 |
-
<
|
| 47 |
-
<
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 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 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
) : (
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
<
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
import {
|
| 4 |
-
import {
|
| 5 |
-
import {
|
| 6 |
-
import {
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 "{playlistSearch}"
|
| 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 |
-
|
| 135 |
-
|
|
|
|
| 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 [
|
| 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
|
| 68 |
} else {
|
| 69 |
-
result = await patch<{ workflow: AutomationWorkflow }>(`/api/workflows/${params.id}`, workflowData
|
| 70 |
}
|
| 71 |
|
| 72 |
if (result && result.workflow) {
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 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
|
| 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 |
-
{
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 ||
|
| 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 {
|
| 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 |
-
<
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 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 |
-
</
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
| 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[]
|
| 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 |
-
{
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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
|
| 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 =
|
| 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
|
| 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 =
|
| 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
|
| 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
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
|
|
|
| 7 |
}
|
| 8 |
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
params.append("url", mediaUrls[0]);
|
| 24 |
-
if (content) params.append("caption", content); // FB photos use 'caption' not 'message'
|
| 25 |
-
}
|
| 26 |
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
|
|
|
| 33 |
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
}
|
| 62 |
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
-
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
-
|
| 81 |
-
}
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 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 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 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 |
+
}
|