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:
Claude committed 2026-08-11 14:29:38 +00:00
1 parent 2188fa458d
commit b95cd23ebc
27 files changed
+1679 -183

No files matched your search

+4 -1
View File
@@ -119,9 +119,12 @@ export default function NewPostMobile() {
const [postType, setPostType] = useState<'feed' | 'story' | 'both'>('feed');
const [previewModalOpen, setPreviewModalOpen] = useState(false);
const { data: pages = [] } = useQuery<SocialPage[]>({
// Ce composeur gère les publications feed/story : TikTok n'accepte que des
// vidéos et se pilote depuis la création de reel.
const { data: allPages = [] } = useQuery<SocialPage[]>({
queryKey: ['/api/pages'],
});
const pages = allPages.filter(p => p.platform !== 'tiktok');
const { data: allMedia = [] } = useQuery<Media[]>({
queryKey: ['/api/media'],
+44 -13
View File
@@ -18,6 +18,7 @@ import { Slider } from "@/components/ui/slider";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
import type { SocialPage, Media } from "@shared/schema";
import { SiFacebook, SiTiktok } from "react-icons/si";
import { DateTimePicker } from "@/components/datetime-picker";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { Checkbox } from "@/components/ui/checkbox";
@@ -114,6 +115,24 @@ export default function MobileNewReel() {
queryKey: ['/api/pages'],
});
// Destinations acceptant une vidéo verticale
const facebookPages = pages.filter(p => p.platform === 'facebook');
const tiktokAccounts = pages.filter(p => p.platform === 'tiktok');
const renderTargetCheckbox = (page: SocialPage) => (
<div key={page.id} className="flex items-center space-x-3 bg-secondary/20 p-3 rounded-lg">
<Checkbox
id={page.id}
checked={selectedPages.includes(page.id)}
onCheckedChange={(checked) => {
if (checked) setSelectedPages([...selectedPages, page.id]);
else setSelectedPages(selectedPages.filter(id => id !== page.id));
}}
/>
<label htmlFor={page.id} className="font-medium flex-1">{page.pageName}</label>
</div>
);
const { data: allMedia = [] } = useQuery<Media[]>({
queryKey: ['/api/media'],
});
@@ -585,20 +604,32 @@ export default function MobileNewReel() {
<CardHeader className="pb-3">
<CardTitle className="text-base">Diffusion</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{pages.filter(p => p.platform === 'facebook').map((page) => (
<div key={page.id} className="flex items-center space-x-3 bg-secondary/20 p-3 rounded-lg">
<Checkbox
id={page.id}
checked={selectedPages.includes(page.id)}
onCheckedChange={(checked) => {
if (checked) setSelectedPages([...selectedPages, page.id]);
else setSelectedPages(selectedPages.filter(id => id !== page.id));
}}
/>
<label htmlFor={page.id} className="font-medium flex-1">{page.pageName}</label>
<CardContent className="space-y-4">
{facebookPages.length === 0 && tiktokAccounts.length === 0 && (
<p className="text-sm text-muted-foreground">
Aucune page Facebook ni compte TikTok connecté.
</p>
)}
{facebookPages.length > 0 && (
<div className="space-y-3">
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<SiFacebook className="w-4 h-4 text-[#1877F2]" />
Pages Facebook
</div>
{facebookPages.map(renderTargetCheckbox)}
</div>
))}
)}
{tiktokAccounts.length > 0 && (
<div className="space-y-3">
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<SiTiktok className="w-4 h-4" />
Comptes TikTok
</div>
{tiktokAccounts.map(renderTargetCheckbox)}
</div>
)}
</CardContent>
</Card>
+97 -17
View File
@@ -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,73 @@ 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;
}
/**
* 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"
className="w-full min-h-[48px]"
onClick={handleClick}
data-testid="button-connect-tiktok"
>
<SiTiktok className="w-5 h-5 mr-2" />
Connecter un compte TikTok
</Button>
);
}
export default function PagesManagementMobile() {
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 +181,11 @@ export default function PagesManagementMobile() {
<div className="space-y-2">
<h1 className="text-2xl font-bold text-foreground">Pages gérées</h1>
<p className="text-sm text-muted-foreground">
Connectez et gérez vos pages Facebook et Instagram
Connectez et gérez vos pages Facebook, Instagram et vos comptes TikTok
</p>
</div>
<ConnectTiktokButton />
<AddPageDialog open={dialogOpen} onOpenChange={setDialogOpen} />
<EditPageDialog page={editingPage} onOpenChange={(open) => !open && setEditingPage(null)} />
@@ -181,6 +244,10 @@ export default function PagesManagementMobile() {
<div className="w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0">
<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 flex-shrink-0">
<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 flex-shrink-0">
<Instagram className="w-6 h-6 text-white" />
@@ -202,20 +269,21 @@ export default function PagesManagementMobile() {
{/* 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-3 rounded-lg ${expirationStatus.bgColor}`}>
<Calendar className="w-4 h-4 flex-shrink-0" />
<div className="flex-1 min-w-0">
<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>
@@ -227,15 +295,27 @@ export default function PagesManagementMobile() {
})()}
<div className="flex items-center gap-2 pt-2">
<Button
variant="outline"
className="flex-1 min-h-[44px]"
onClick={() => setEditingPage(page)}
data-testid={`button-edit-page-${page.id}`}
>
<Edit className="w-4 h-4 mr-2" />
Modifier
</Button>
{page.platform === 'tiktok' ? (
<Button
variant="outline"
className="flex-1 min-h-[44px]"
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="outline"
className="flex-1 min-h-[44px]"
onClick={() => setEditingPage(page)}
data-testid={`button-edit-page-${page.id}`}
>
<Edit className="w-4 h-4 mr-2" />
Modifier
</Button>
)}
<Button
variant="destructive"
className="flex-1 min-h-[44px]"
+44 -21
View File
@@ -15,6 +15,7 @@ import { DateTimePicker } from "@/components/datetime-picker";
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
import type { Media, SocialPage } from "@shared/schema";
import { SiFacebook, SiTiktok } from "react-icons/si";
interface AudioTrack { id: string; title: string; fileName: string; url: string; duration: number; }
@@ -68,6 +69,23 @@ export default function MobileRemotionVideoPage() {
const { data: socialPages = [] } = useQuery<SocialPage[]>({ queryKey: ['/api/pages'] });
const facebookPages = socialPages.filter(p => p.platform === 'facebook');
const tiktokAccounts = socialPages.filter(p => p.platform === 'tiktok');
const renderTargetCheckbox = (page: SocialPage) => (
<div key={page.id} className="flex items-center gap-3">
<Checkbox
id={`page-${page.id}`}
checked={selectedPageIds.includes(page.id)}
onCheckedChange={checked =>
setSelectedPageIds(prev => checked ? [...prev, page.id] : prev.filter(id => id !== page.id))
}
/>
<label htmlFor={`page-${page.id}`} className="text-sm font-medium cursor-pointer">
{page.pageName}
</label>
</div>
);
const imageMediaList = allMedia.filter(m => m.type === 'image').slice(0, 20);
const generateTextMutation = useMutation({
@@ -430,31 +448,36 @@ export default function MobileRemotionVideoPage() {
</CardContent>
</Card>
{/* 4. Page Facebook */}
{/* 4. Destinations */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2"><Send className="w-4 h-4" /> 4. Page Facebook</CardTitle>
<CardTitle className="text-base flex items-center gap-2"><Send className="w-4 h-4" /> 4. Destinations</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{facebookPages.length === 0 ? (
<p className="text-xs text-muted-foreground">Aucune page Facebook connectée.</p>
{facebookPages.length === 0 && tiktokAccounts.length === 0 ? (
<p className="text-xs text-muted-foreground">Aucune page Facebook ni compte TikTok connecté.</p>
) : (
facebookPages.map(page => (
<div key={page.id} className="flex items-center gap-3">
<Checkbox
id={`page-${page.id}`}
checked={selectedPageIds.includes(page.id)}
onCheckedChange={checked =>
setSelectedPageIds(prev =>
checked ? [...prev, page.id] : prev.filter(id => id !== page.id)
)
}
/>
<label htmlFor={`page-${page.id}`} className="text-sm font-medium cursor-pointer">
{page.pageName}
</label>
</div>
))
<>
{facebookPages.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<SiFacebook className="w-4 h-4 text-[#1877F2]" />
Pages Facebook
</div>
{facebookPages.map(renderTargetCheckbox)}
</div>
)}
{tiktokAccounts.length > 0 && (
<div className="space-y-2 pt-2">
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<SiTiktok className="w-4 h-4" />
Comptes TikTok
</div>
{tiktokAccounts.map(renderTargetCheckbox)}
</div>
)}
</>
)}
</CardContent>
</Card>
@@ -484,7 +507,7 @@ export default function MobileRemotionVideoPage() {
<p className="text-xs text-muted-foreground">Sélectionnez une page ci-dessus.</p>
) : (
<p className="text-xs text-primary font-medium">
Pages : {facebookPages.filter(p => selectedPageIds.includes(p.id)).map(p => p.pageName).join(', ')}
Destinations : {[...facebookPages, ...tiktokAccounts].filter(p => selectedPageIds.includes(p.id)).map(p => p.pageName).join(', ')}
</p>
)}
<Textarea rows={2} placeholder="Description (optionnel)" value={publishDescription}