diff --git a/app/api/tiktok/disconnect/route.ts b/app/api/tiktok/disconnect/route.ts index 3634f73..d0fcd49 100644 --- a/app/api/tiktok/disconnect/route.ts +++ b/app/api/tiktok/disconnect/route.ts @@ -15,12 +15,8 @@ export async function POST() { } try { - // Supprimer le token TikTok await prisma.tikTokToken.deleteMany({ where: { userId } }); - // Supprimer le TrackedAccount associé - await prisma.trackedAccount.deleteMany({ where: { userId, platform: "tiktok" } }); - return NextResponse.json({ success: true }); } catch (err) { console.error("[TikTok disconnect error]", err); diff --git a/app/api/tiktok/snapshots/route.ts b/app/api/tiktok/snapshots/route.ts index 1ed2e54..30dc9da 100644 --- a/app/api/tiktok/snapshots/route.ts +++ b/app/api/tiktok/snapshots/route.ts @@ -77,6 +77,7 @@ export async function GET(req: NextRequest) { followers: true, likes: true, videoCount: true, + views: true, }, }); @@ -88,7 +89,7 @@ export async function POST(req: NextRequest) { if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); const body = await req.json(); - const { followers, likes, videoCount, displayName, openId } = body; + const { followers, likes, videoCount, views, displayName, openId } = body; if (followers === undefined) { return NextResponse.json({ error: "Champ 'followers' requis" }, { status: 400 }); @@ -115,7 +116,7 @@ export async function POST(req: NextRequest) { followers: followers ?? 0, likes: likes ?? 0, videoCount: videoCount ?? 0, - views: 0, + views: views ?? 0, }, }); diff --git a/app/api/tiktok/stats/route.ts b/app/api/tiktok/stats/route.ts index b70e58f..a0e4421 100644 --- a/app/api/tiktok/stats/route.ts +++ b/app/api/tiktok/stats/route.ts @@ -21,6 +21,8 @@ export async function GET() { followers: 124, likes: 856, videoCount: 1, + views: 3432, + profileViews: 287, displayName: "CrowMate studio", avatarUrl: "", plan: (user as any)?.plan ?? "free", @@ -56,8 +58,7 @@ export async function GET() { try { const stats = await fetchUserStats(accessToken, openId); - - // Upsert TrackedAccount + snapshot automatique + try { let account = await prisma.trackedAccount.findFirst({ where: { userId, platform: "tiktok" }, @@ -78,14 +79,12 @@ export async function GET() { followers: stats.followers ?? 0, likes: stats.likes ?? 0, videoCount: stats.videoCount ?? 0, - views: 0, + views: stats.views ?? 0, }, }); } catch (snapshotErr) { console.error("[TikTok snapshot save error]", snapshotErr); } - - // Inclure le plan dans la réponse const user = await prisma.user.findUnique({ where: { id: userId } }); return NextResponse.json({ ...stats, plan: (user as any)?.plan ?? "free" }); } catch (err) { diff --git a/app/tiktok/page.tsx b/app/tiktok/page.tsx index 7eff67f..3845535 100644 --- a/app/tiktok/page.tsx +++ b/app/tiktok/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { Music2, Link2, Unlink, Loader2, AlertTriangle } from "lucide-react"; +import { Music2, Link2, Unlink, Loader2, AlertTriangle, Eye, EyeOff } from "lucide-react"; import { useEffect, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; @@ -11,16 +11,49 @@ interface TikTokStats { followers: number; likes: number; videoCount: number; + views: number; displayName: string; avatarUrl: string; plan: "free" | "pro" | "elite" | "team"; } +type CardId = "followers" | "likes" | "videos" | "views" | "ratio"; + +const defaultVisibleCards: Record = { + followers: true, + likes: true, + videos: true, + views: true, + ratio: true, +}; + +const visibleCardsStorageKey = "tiktok.visibleCards"; + +function parseVisibleCards(value: string | null): Record { + if (!value) return defaultVisibleCards; + + try { + const parsed = JSON.parse(value) as Partial>; + return { + followers: typeof parsed.followers === "boolean" ? parsed.followers : defaultVisibleCards.followers, + likes: typeof parsed.likes === "boolean" ? parsed.likes : defaultVisibleCards.likes, + videos: typeof parsed.videos === "boolean" ? parsed.videos : defaultVisibleCards.videos, + views: typeof parsed.views === "boolean" ? parsed.views : defaultVisibleCards.views, + ratio: typeof parsed.ratio === "boolean" ? parsed.ratio : defaultVisibleCards.ratio, + }; + } catch { + return defaultVisibleCards; + } +} + export default function TikTokPage() { const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [disconnecting, setDisconnecting] = useState(false); + const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false); + const [visibleCards, setVisibleCards] = useState>(defaultVisibleCards); + const [visibleCardsHydrated, setVisibleCardsHydrated] = useState(false); const searchParams = useSearchParams(); const router = useRouter(); @@ -46,14 +79,24 @@ export default function TikTokPage() { } useEffect(() => { + const persisted = parseVisibleCards(window.localStorage.getItem(visibleCardsStorageKey)); + setVisibleCards(persisted); + setVisibleCardsHydrated(true); loadStats(); }, []); + useEffect(() => { + if (!visibleCardsHydrated) return; + window.localStorage.setItem(visibleCardsStorageKey, JSON.stringify(visibleCards)); + }, [visibleCards, visibleCardsHydrated]); + async function handleDisconnect() { + if (disconnecting) return; setDisconnecting(true); try { await fetch("/api/tiktok/disconnect", { method: "POST" }); setStats(null); + setShowDisconnectConfirm(false); router.replace("/tiktok"); } finally { setDisconnecting(false); @@ -62,6 +105,49 @@ export default function TikTokPage() { const urlError = searchParams.get("error"); + const cards = stats + ? [ + { + id: "followers" as const, + label: "Followers", + value: stats.followers.toLocaleString("fr-FR"), + sub: "Abonnés totaux", + accent: "purple" as const, + }, + { + id: "likes" as const, + label: "Likes totaux", + value: stats.likes.toLocaleString("fr-FR"), + sub: "Cumul likes", + accent: "red" as const, + }, + { + id: "videos" as const, + label: "Vidéos publiées", + value: stats.videoCount.toLocaleString("fr-FR"), + sub: "Vidéos au total", + accent: "blue" as const, + }, + { + id: "views" as const, + label: "Vues vidéos", + value: stats.views.toLocaleString("fr-FR"), + sub: "Vues totales des vidéos", + accent: "green" as const, + }, + { + id: "ratio" as const, + label: "Ratio likes/vidéo", + value: stats.videoCount > 0 ? Math.round(stats.likes / stats.videoCount).toLocaleString("fr-FR") : "—", + sub: "Moy. likes par vidéo", + accent: "gold" as const, + }, + ] + : []; + + const shownCards = cards.filter((card) => visibleCards[card.id]); + const hiddenCards = cards.filter((card) => !visibleCards[card.id]); + return (
{/* Header */} @@ -78,7 +164,7 @@ export default function TikTokPage() { {stats && !loading && (
)} -
- - - - 0 ? Math.round(stats.likes / stats.videoCount).toLocaleString("fr-FR") : "—"} - sub="Moy. likes par vidéo" - accent="gold" - /> -
+ {hiddenCards.length > 0 && ( +
+ {hiddenCards.map((card) => ( + + ))} + +
+ )} + + {shownCards.length > 0 ? ( +
+ {shownCards.map((card) => ( +
+ + +
+ ))} +
+ ) : ( +
+

+ Toutes les cartes sont masquées +

+ +
+ )} {/* Graph */} @@ -180,6 +303,40 @@ export default function TikTokPage() { )} + + {showDisconnectConfirm && ( +
+
+
+ +

Confirmer la déconnexion

+
+

+ Êtes-vous sûr de vouloir déconnecter votre compte TikTok ? +

+
+ + +
+
+
+ )} ); } \ No newline at end of file diff --git a/components/StatsCharts.tsx b/components/StatsCharts.tsx index ade19c6..f2e8914 100644 --- a/components/StatsCharts.tsx +++ b/components/StatsCharts.tsx @@ -13,13 +13,14 @@ import { import { TrendingUp, TrendingDown, Minus, Lock } from "lucide-react"; type Period = "7d" | "30d" | "90d" | "all"; -type Metric = "followers" | "likes" | "videoCount"; +type Metric = "followers" | "likes" | "videoCount" | "views"; interface Snapshot { createdAt: string; followers: number; likes: number; videoCount: number; + views: number; } interface StatsChartProps { @@ -39,6 +40,7 @@ const METRICS: { value: Metric; label: string; color: string; gradientId: string { value: "followers", label: "Followers", color: "#c084fc", gradientId: "gradFollowers" }, { value: "likes", label: "Likes", color: "#f472b6", gradientId: "gradLikes" }, { value: "videoCount", label: "Vidéos", color: "#60a5fa", gradientId: "gradVideos" }, + { value: "views", label: "Vues", color: "#22c55e", gradientId: "gradViews" }, ]; function canAccess(plan: string, required: string) { diff --git a/docker-compose.yml b/docker-compose.yml index 19901df..6898059 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -62,7 +62,7 @@ services: condition: service_healthy app: condition: service_started - command: sh -c "sleep 30 && node worker/snapshot-worker.js" + command: sh -c "sleep 30 && tsx worker/snapshot-worker.ts" volumes: postgres_data: diff --git a/lib/tiktok.ts b/lib/tiktok.ts index 4005d88..a2a8324 100644 --- a/lib/tiktok.ts +++ b/lib/tiktok.ts @@ -1,6 +1,7 @@ const TIKTOK_AUTH_URL = "https://www.tiktok.com/v2/auth/authorize"; const TIKTOK_TOKEN_URL = "https://open.tiktokapis.com/v2/oauth/token/"; const TIKTOK_USER_INFO_URL = "https://open.tiktokapis.com/v2/user/info/"; +const TIKTOK_VIDEO_LIST_URL = "https://open.tiktokapis.com/v2/video/list/"; const CLIENT_KEY = process.env.TIKTOK_CLIENT_KEY!; const CLIENT_SECRET = process.env.TIKTOK_CLIENT_SECRET!; @@ -38,7 +39,8 @@ export function getTikTokAuthUrl(state: string, codeChallenge: string): string { const params = new URLSearchParams({ client_key: CLIENT_KEY, response_type: "code", - scope: "user.info.basic,user.info.stats", + // video.list is used as a fallback to compute total views from all videos. + scope: "user.info.basic,user.info.stats,video.list", redirect_uri: getRedirectUri(), state, code_challenge: codeChallenge, @@ -115,13 +117,57 @@ export interface TikTokUserStats { followers: number; likes: number; videoCount: number; + views: number; + profileViews: number; username: string; displayName: string; avatarUrl: string; } +async function fetchTotalVideoViews(accessToken: string): Promise { + const fields = "id,view_count"; + let cursor = 0; + let totalViews = 0; + + for (let page = 0; page < 50; page++) { + const res = await fetch(`${TIKTOK_VIDEO_LIST_URL}?fields=${fields}`, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ max_count: 20, cursor }), + }); + + const data = await res.json(); + + if (!res.ok || data.error?.code !== "ok") { + throw new Error(data.error?.message || "TikTok video list fetch failed"); + } + + const videos = Array.isArray(data.data?.videos) ? data.data.videos : []; + for (const video of videos) { + totalViews += Number(video?.view_count ?? 0); + } + + const hasMore = Boolean(data.data?.has_more); + if (!hasMore) { + break; + } + + const nextCursor = Number(data.data?.cursor ?? cursor + videos.length); + if (!Number.isFinite(nextCursor) || nextCursor <= cursor) { + break; + } + + cursor = nextCursor; + } + + return totalViews; +} + export async function fetchUserStats(accessToken: string, openId: string): Promise { - const fields = "follower_count,following_count,likes_count,video_count,display_name,avatar_url"; + const fields = "follower_count,following_count,likes_count,video_count,video_view_count,profile_view_count,display_name,avatar_url"; const url = `${TIKTOK_USER_INFO_URL}?fields=${fields}`; const res = await fetch(url, { @@ -137,11 +183,27 @@ export async function fetchUserStats(accessToken: string, openId: string): Promi } const user = data.data?.user ?? {}; + let views = user.video_view_count ?? user.profile_view_count ?? 0; + + // Some TikTok apps do not receive video_view_count in user.info.stats. + // In that case we compute total views from the account video list. + if (user.video_view_count == null) { + try { + const computedViews = await fetchTotalVideoViews(accessToken); + if (computedViews > 0) { + views = computedViews; + } + } catch (err) { + console.warn("[TikTok views fallback error]", err); + } + } return { followers: user.follower_count ?? 0, likes: user.likes_count ?? 0, videoCount: user.video_count ?? 0, + views, + profileViews: user.profile_view_count ?? 0, username: openId, displayName: user.display_name ?? "", avatarUrl: user.avatar_url ?? "", diff --git a/worker/Dockerfile b/worker/Dockerfile index b6dadaf..a8891b5 100644 --- a/worker/Dockerfile +++ b/worker/Dockerfile @@ -2,21 +2,18 @@ FROM node:20-alpine WORKDIR /app -# Copie les fichiers nécessaires COPY package*.json ./ COPY prisma.config.ts ./ COPY prisma ./prisma/ COPY tsconfig.json ./ -# Install deps RUN npm ci -# Génère le client Prisma dans app/generated/prisma (output défini dans schema.prisma) RUN npx prisma generate -# Copie le code du worker ET le client Prisma généré COPY worker ./worker/ COPY app/generated ./app/generated/ -# Lance le worker avec tsx -CMD ["npx", "tsx", "worker/snapshot-worker.ts"] +RUN npm install -g tsx + +CMD ["tsx", "worker/snapshot-worker.ts"] \ No newline at end of file diff --git a/worker/snapshot-worker.ts b/worker/snapshot-worker.ts index 0e30a15..4a5a771 100644 --- a/worker/snapshot-worker.ts +++ b/worker/snapshot-worker.ts @@ -17,6 +17,7 @@ const prisma = new PrismaClient({ adapter }); const TIKTOK_TOKEN_URL = "https://open.tiktokapis.com/v2/oauth/token/"; const TIKTOK_USER_INFO_URL = "https://open.tiktokapis.com/v2/user/info/"; +const TIKTOK_VIDEO_LIST_URL = "https://open.tiktokapis.com/v2/video/list/"; const CLIENT_KEY = process.env.TIKTOK_CLIENT_KEY!; const CLIENT_SECRET = process.env.TIKTOK_CLIENT_SECRET!; const INTERVAL_MS = 60 * 60 * 1000; // 1 heure @@ -57,7 +58,7 @@ async function refreshTikTokToken(refreshTokenStr: string) { } async function fetchTikTokStats(accessToken: string) { - const fields = "follower_count,likes_count,video_count,display_name"; + const fields = "follower_count,likes_count,video_count,video_view_count,display_name"; const res = await fetch(`${TIKTOK_USER_INFO_URL}?fields=${fields}`, { headers: { Authorization: `Bearer ${accessToken}` }, }); @@ -68,14 +69,66 @@ async function fetchTikTokStats(accessToken: string) { } const user = data.data?.user ?? {}; + let views = (user.video_view_count ?? user.profile_view_count ?? 0) as number; + + if (user.video_view_count == null) { + try { + views = await fetchTotalVideoViews(accessToken); + } catch (err) { + console.warn("[worker] fallback views failed:", err); + } + } + return { followers: (user.follower_count ?? 0) as number, likes: (user.likes_count ?? 0) as number, videoCount: (user.video_count ?? 0) as number, + views, displayName: (user.display_name ?? "") as string, }; } +async function fetchTotalVideoViews(accessToken: string): Promise { + const fields = "id,view_count"; + let cursor = 0; + let totalViews = 0; + + for (let page = 0; page < 50; page++) { + const res = await fetch(`${TIKTOK_VIDEO_LIST_URL}?fields=${fields}`, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ max_count: 20, cursor }), + }); + const data = await res.json(); + + if (!res.ok || data.error?.code !== "ok") { + throw new Error(data.error?.message ?? "Video list fetch failed"); + } + + const videos = Array.isArray(data.data?.videos) ? data.data.videos : []; + for (const video of videos) { + totalViews += Number(video?.view_count ?? 0); + } + + const hasMore = Boolean(data.data?.has_more); + if (!hasMore) { + break; + } + + const nextCursor = Number(data.data?.cursor ?? cursor + videos.length); + if (!Number.isFinite(nextCursor) || nextCursor <= cursor) { + break; + } + + cursor = nextCursor; + } + + return totalViews; +} + // ── Core job ────────────────────────────────────────────────── async function runSnapshots() { @@ -136,11 +189,11 @@ async function runSnapshots() { followers: stats.followers, likes: stats.likes, videoCount: stats.videoCount, - views: 0, + views: stats.views, }, }); - console.log(`[worker] ✓ userId=${userId} — followers=${stats.followers} likes=${stats.likes} videos=${stats.videoCount}`); + console.log(`[worker] ✓ userId=${userId} — followers=${stats.followers} likes=${stats.likes} videos=${stats.videoCount} views=${stats.views}`); } catch (err) { console.error(`[worker] ✗ erreur userId=${userId}:`, err);