mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-23 15:08:13 +02:00
feat(tiktok): publier les reels sur les comptes TikTok des magasins
Ajoute la gestion multi-comptes TikTok et permet d'envoyer la même vidéo sur plusieurs pages Facebook ET plusieurs comptes TikTok en une seule publication. Un compte TikTok vit dans social_pages comme une page Facebook : il hérite donc des permissions par utilisateur, de la planification et du calendrier. Trois particularités de l'API TikTok structurent l'implémentation : - pas de jeton saisi à la main : chaque compte passe par OAuth, et l'access token (24h) est renouvelé via le refresh token (1 an) avant chaque publication ; - la publication est asynchrone : l'upload rend un publish_id, et un poller récupère l'identifiant définitif du post ; - creator_info doit être interrogé avant chaque envoi pour ne demander qu'un niveau de confidentialité réellement autorisé sur le compte. Serveur : - service TikTok (OAuth, creator_info, upload FILE_UPLOAD par chunks, suivi de statut) et routes de connexion/configuration - schéma : platform 'tiktok', refresh token et scopes sur social_pages, publish_id/publish_status sur scheduled_posts, table tiktok_config - routage par plateforme dans les deux flux de reels et le planificateur - cron de suivi des publications, token manager et analytics adaptés Client : - connexion et reconnexion des comptes TikTok depuis « Pages gérées » - sélection combinée pages Facebook / comptes TikTok à la publication - configuration de l'application TikTok dans les paramètres (admin) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018uUPZ1GNpEkYB1dpXHYPek
This commit is contained in:
27 files changed
+1679
-183
No files matched your search
+95
-17
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { Plus, Facebook, Instagram, Trash2, RefreshCw, Edit, Bug, Code, Calendar, AlertTriangle } from "lucide-react";
|
||||
import { SiTiktok } from "react-icons/si";
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import TopBar from "@/components/topbar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -66,12 +67,37 @@ function getTokenExpirationStatus(expiresAt: string | null | undefined) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Un compte TikTok n'a pas de "token de page" à surveiller : ce qui compte est la
|
||||
* validité de l'autorisation, portée par le refresh token (1 an).
|
||||
*/
|
||||
function getConnectionExpiry(page: SocialPage): string | null {
|
||||
const date = page.platform === 'tiktok' ? page.refreshTokenExpiresAt : page.tokenExpiresAt;
|
||||
return date ? new Date(date).toISOString() : null;
|
||||
}
|
||||
|
||||
export default function PagesManagement() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingPage, setEditingPage] = useState<SocialPage | null>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
// Retour du flux d'autorisation TikTok (/api/tiktok/callback redirige ici)
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const result = params.get('tiktok');
|
||||
if (!result) return;
|
||||
|
||||
toast({
|
||||
title: result === 'success' ? 'Compte TikTok connecté' : 'Connexion TikTok impossible',
|
||||
description: params.get('message') || undefined,
|
||||
variant: result === 'success' ? undefined : 'destructive',
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/pages'] });
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
}, [toast]);
|
||||
|
||||
const { data: pages = [], isLoading } = useQuery<SocialPage[]>({
|
||||
queryKey: ['/api/pages'],
|
||||
});
|
||||
@@ -119,10 +145,13 @@ export default function PagesManagement() {
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Pages gérées</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Connectez et gérez vos pages Facebook et Instagram
|
||||
Connectez et gérez vos pages Facebook, Instagram et vos comptes TikTok
|
||||
</p>
|
||||
</div>
|
||||
<AddPageDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
||||
<div className="flex items-center gap-3">
|
||||
<ConnectTiktokButton />
|
||||
<AddPageDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
||||
</div>
|
||||
<EditPageDialog page={editingPage} onOpenChange={(open) => !open && setEditingPage(null)} />
|
||||
</div>
|
||||
|
||||
@@ -194,6 +223,10 @@ export default function PagesManagement() {
|
||||
<div className="w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center">
|
||||
<Facebook className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
) : page.platform === 'tiktok' ? (
|
||||
<div className="w-12 h-12 bg-black rounded-lg flex items-center justify-center">
|
||||
<SiTiktok className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-purple-500 to-pink-500 rounded-lg flex items-center justify-center">
|
||||
<Instagram className="w-6 h-6 text-white" />
|
||||
@@ -215,20 +248,21 @@ export default function PagesManagement() {
|
||||
|
||||
{/* Token expiration status */}
|
||||
{(() => {
|
||||
const expirationStatus = getTokenExpirationStatus(
|
||||
page.tokenExpiresAt ? new Date(page.tokenExpiresAt).toISOString() : null
|
||||
);
|
||||
const expiresAt = getConnectionExpiry(page);
|
||||
const expirationStatus = getTokenExpirationStatus(expiresAt);
|
||||
return (
|
||||
<div className={`flex items-center gap-2 text-xs px-3 py-2 rounded-lg ${expirationStatus.bgColor}`}>
|
||||
<Calendar className="w-4 h-4" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">Expiration du token</div>
|
||||
<div className="font-medium">
|
||||
{page.platform === 'tiktok' ? 'Expiration de la connexion' : 'Expiration du token'}
|
||||
</div>
|
||||
<div className={`${expirationStatus.color} font-semibold`}>
|
||||
{expirationStatus.message}
|
||||
</div>
|
||||
{page.tokenExpiresAt && (
|
||||
{expiresAt && (
|
||||
<div className="text-muted-foreground mt-0.5">
|
||||
{format(new Date(page.tokenExpiresAt), "d MMMM yyyy", { locale: fr })}
|
||||
{format(new Date(expiresAt), "d MMMM yyyy", { locale: fr })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -240,14 +274,27 @@ export default function PagesManagement() {
|
||||
})()}
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setEditingPage(page)}
|
||||
data-testid={`button-edit-page-${page.id}`}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
{page.platform === 'tiktok' ? (
|
||||
// Un token TikTok ne se saisit pas : on repasse par l'autorisation
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => { window.location.href = '/api/tiktok/connect'; }}
|
||||
data-testid={`button-reconnect-page-${page.id}`}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Reconnecter
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setEditingPage(page)}
|
||||
data-testid={`button-edit-page-${page.id}`}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -269,6 +316,37 @@ export default function PagesManagement() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lance l'autorisation TikTok. La connexion se fait par navigation complète du
|
||||
* navigateur (et non en fetch) puisqu'elle passe par le site de TikTok.
|
||||
*/
|
||||
function ConnectTiktokButton() {
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: config } = useQuery<{ configured: boolean }>({
|
||||
queryKey: ['/api/tiktok/config'],
|
||||
});
|
||||
|
||||
const handleClick = () => {
|
||||
if (config && !config.configured) {
|
||||
toast({
|
||||
title: "TikTok n'est pas configuré",
|
||||
description: "Renseignez le client key et le client secret de votre application TikTok dans les paramètres.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
window.location.href = '/api/tiktok/connect';
|
||||
};
|
||||
|
||||
return (
|
||||
<Button variant="outline" onClick={handleClick} data-testid="button-connect-tiktok">
|
||||
<SiTiktok className="w-4 h-4 mr-2" />
|
||||
Connecter un compte TikTok
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function AddPageDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
const [platform, setPlatform] = useState<'facebook' | 'instagram'>('facebook');
|
||||
const [pageName, setPageName] = useState('');
|
||||
|
||||
Reference in new issue
Block a user