Files
Socialflow/client/src/components/calendar-list-view.tsx
T
Claude b95cd23ebc 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
2026-08-11 14:29:38 +00:00

241 lines
9.9 KiB
TypeScript

import { format, isSameDay, parseISO } from "date-fns";
import { fr } from "date-fns/locale";
import { Edit, Trash2, ChevronDown, ChevronUp, Eye, Image, Smartphone } from "lucide-react";
import { SiFacebook, SiInstagram, SiTiktok } from "react-icons/si";
import { Button } from "@/components/ui/button";
import { useState } from "react";
interface CalendarListViewProps {
scheduledPosts: any[];
onEditPost: (post: any) => void;
onDeletePost: (postId: string) => void;
onPreviewPost: (post: any) => void;
}
export default function CalendarListView({ scheduledPosts, onEditPost, onDeletePost, onPreviewPost }: CalendarListViewProps) {
const [expandedDates, setExpandedDates] = useState<Set<string>>(new Set());
const getPostTypeIcon = (postType: string) => {
if (postType === 'feed') {
return <Image className="w-4 h-4" />;
} else if (postType === 'story') {
return <Smartphone className="w-4 h-4" />;
} else if (postType === 'both') {
return (
<div className="flex gap-0.5">
<Image className="w-3 h-3" />
<Smartphone className="w-3 h-3" />
</div>
);
}
return null;
};
// Group posts by date
const groupedPosts = scheduledPosts.reduce((acc: { [key: string]: any[] }, post) => {
if (!post.scheduledAt) return acc;
const date = format(parseISO(post.scheduledAt), "yyyy-MM-dd");
if (!acc[date]) {
acc[date] = [];
}
acc[date].push(post);
return acc;
}, {});
// Sort dates - put today first, then future dates in ascending order, then past dates
const today = format(new Date(), "yyyy-MM-dd");
const sortedDates = Object.keys(groupedPosts).sort((a, b) => {
// Today always comes first
if (a === today && b !== today) return -1;
if (b === today && a !== today) return 1;
// Both are not today - separate future and past dates
const aIsFuture = a > today;
const bIsFuture = b > today;
// Both are future: ascending chronological order (soonest first)
if (aIsFuture && bIsFuture) {
return a.localeCompare(b);
}
// Both are past: ascending chronological order (most recent past first)
if (!aIsFuture && !bIsFuture) {
return b.localeCompare(a);
}
// One is future, one is past: future comes first
return aIsFuture ? -1 : 1;
});
const toggleDate = (date: string) => {
const newExpanded = new Set(expandedDates);
if (newExpanded.has(date)) {
newExpanded.delete(date);
} else {
newExpanded.add(date);
}
setExpandedDates(newExpanded);
};
const isToday = (dateStr: string) => {
return isSameDay(parseISO(dateStr), new Date());
};
return (
<div className="space-y-3 px-4 pb-4">
{sortedDates.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
Aucune publication programmée
</div>
) : (
sortedDates.map((dateStr, index) => {
const posts = groupedPosts[dateStr];
const isExpanded = expandedDates.has(dateStr);
const dateObj = parseISO(dateStr);
const isDateToday = isToday(dateStr);
// Check if we need to show the separator (first past date)
const isPastDate = dateStr < today;
const prevDate = index > 0 ? sortedDates[index - 1] : null;
const prevWasFutureOrToday = prevDate ? (prevDate >= today) : false;
const showSeparator = isPastDate && prevWasFutureOrToday;
return (
<div key={`wrapper-${dateStr}`}>
{showSeparator && (
<div
key="past-separator"
className="flex items-center gap-3 py-2"
data-testid="separator-past-dates"
>
<div className="flex-1 h-px bg-muted-foreground/30"></div>
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Publications passées
</span>
<div className="flex-1 h-px bg-muted-foreground/30"></div>
</div>
)}
<div
className={`rounded-xl border ${isDateToday ? 'border-primary/50 bg-primary/5' : 'border-border/50 bg-card'} overflow-hidden`}
data-testid={`calendar-list-date-${dateStr}`}
>
<button
onClick={() => toggleDate(dateStr)}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-muted/30 transition-colors"
data-testid={`button-toggle-date-${dateStr}`}
>
<div className="flex items-center gap-3">
<div className="text-left">
<div className="font-semibold text-foreground">
{format(dateObj, "EEEE d MMMM yyyy", { locale: fr })}
</div>
<div className="text-xs text-muted-foreground">
{posts.length} publication{posts.length > 1 ? 's' : ''}
</div>
</div>
{isDateToday && (
<span className="text-xs bg-primary text-primary-foreground px-2 py-1 rounded-full font-semibold">
Aujourd'hui
</span>
)}
</div>
{isExpanded ? (
<ChevronUp className="w-5 h-5 text-muted-foreground" />
) : (
<ChevronDown className="w-5 h-5 text-muted-foreground" />
)}
</button>
{isExpanded && (
<div className="border-t border-border/50">
{posts.map((post: any) => {
const time = post.scheduledAt
? new Date(post.scheduledAt).toLocaleTimeString('fr-FR', {
hour: '2-digit',
minute: '2-digit',
})
: '';
const isPending = !post.publishedAt;
const isPublished = !!post.publishedAt;
const pageName = post.page?.pageName || 'Page inconnue';
const platform = post.page?.platform || 'facebook';
const PlatformIcon = platform === 'instagram' ? SiInstagram : platform === 'tiktok' ? SiTiktok : SiFacebook;
return (
<div
key={post.id}
className={`px-4 py-3 border-b border-border/30 last:border-b-0 ${
isPending ? 'bg-blue-500/5' : 'bg-green-500/5'
}`}
data-testid={`calendar-list-post-${post.id}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
{getPostTypeIcon(post.postType)}
<span className="font-semibold text-lg text-foreground">{time}</span>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
isPending
? 'bg-blue-500/20 text-blue-600'
: 'bg-green-500/20 text-green-600'
}`}>
{isPending ? 'Programmé' : 'Publié'}
</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<PlatformIcon className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{pageName}</span>
</div>
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => onPreviewPost(post)}
className="h-11 w-11 p-0"
data-testid={`button-preview-post-${post.id}`}
title="Prévisualiser"
>
<Eye className={`w-5 h-5 ${isPending ? 'text-blue-500' : 'text-green-500'}`} />
</Button>
{isPending && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => onEditPost(post)}
className="h-11 w-11 p-0"
data-testid={`button-edit-post-${post.id}`}
title="Modifier"
>
<Edit className="w-5 h-5 text-blue-500" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onDeletePost(post.id)}
className="h-11 w-11 p-0"
data-testid={`button-delete-post-${post.id}`}
title="Supprimer"
>
<Trash2 className="w-5 h-5 text-red-500" />
</Button>
</>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
})
)}
</div>
);
}