mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-11 17:26:45 +02:00
Add a date and time picker for scheduling posts
Integrates a new DateTimePicker component into the new post page, replacing the old datetime-local input. This component allows for selecting specific dates and times, and also visually indicates dates with pre-existing scheduled posts. The internal state and API request payload have been updated to correctly handle Date objects instead of strings. Replit-Commit-Author: Agent Replit-Commit-Session-Id: ae4037a0-2a6f-4530-9bac-79b543286bda Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/397bca8c-984f-43ff-841a-10897aeb8140/ae4037a0-2a6f-4530-9bac-79b543286bda/aGnsIb5
This commit is contained in:
1 parent
c7b4b2c653
commit
5681814957
2 files changed
+221
-83
No files matched your search
@@ -0,0 +1,210 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar as CalendarIcon, Clock, X } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
|
||||
interface DateTimePickerProps {
|
||||
value: Date | undefined;
|
||||
onChange: (date: Date | undefined) => void;
|
||||
occupiedDates?: Date[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const OPTIMAL_HOURS = [8, 12, 13, 18, 19, 20];
|
||||
|
||||
export function DateTimePicker({ value, onChange, occupiedDates = [], placeholder = "Sélectionner une date" }: DateTimePickerProps) {
|
||||
const [selectedDate, setSelectedDate] = useState<Date | undefined>(value);
|
||||
const [selectedHour, setSelectedHour] = useState<number>(value?.getHours() || 8);
|
||||
const [selectedMinute, setSelectedMinute] = useState<number>(value?.getMinutes() || 0);
|
||||
|
||||
// Sync internal state with parent value prop
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
setSelectedDate(value);
|
||||
setSelectedHour(value.getHours());
|
||||
setSelectedMinute(value.getMinutes());
|
||||
} else {
|
||||
setSelectedDate(undefined);
|
||||
setSelectedHour(8);
|
||||
setSelectedMinute(0);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const hours = Array.from({ length: 24 }, (_, i) => i);
|
||||
const minutes = [0, 15, 30, 45];
|
||||
|
||||
const isOptimalHour = (hour: number) => OPTIMAL_HOURS.includes(hour);
|
||||
|
||||
const handleDateSelect = (date: Date | undefined) => {
|
||||
setSelectedDate(date);
|
||||
if (date) {
|
||||
const newDate = new Date(date);
|
||||
newDate.setHours(selectedHour);
|
||||
newDate.setMinutes(selectedMinute);
|
||||
onChange(newDate);
|
||||
} else {
|
||||
onChange(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimeChange = (hour: number, minute: number) => {
|
||||
setSelectedHour(hour);
|
||||
setSelectedMinute(minute);
|
||||
if (selectedDate) {
|
||||
const newDate = new Date(selectedDate);
|
||||
newDate.setHours(hour);
|
||||
newDate.setMinutes(minute);
|
||||
onChange(newDate);
|
||||
}
|
||||
};
|
||||
|
||||
// Convert occupied dates to a Set of date strings for efficient lookup
|
||||
const occupiedDateStrings = new Set(
|
||||
occupiedDates.map(date => format(date, 'yyyy-MM-dd'))
|
||||
);
|
||||
|
||||
// Check if a date is occupied
|
||||
const isDateOccupied = (date: Date) => {
|
||||
return occupiedDateStrings.has(format(date, 'yyyy-MM-dd'));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"flex-1 justify-start text-left font-normal",
|
||||
!value && "text-muted-foreground"
|
||||
)}
|
||||
data-testid="button-date-picker"
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{value ? format(value, "PPP", { locale: fr }) : <span>{placeholder}</span>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
onSelect={handleDateSelect}
|
||||
locale={fr}
|
||||
modifiers={{
|
||||
occupied: (date) => isDateOccupied(date)
|
||||
}}
|
||||
modifiersClassNames={{
|
||||
occupied: "bg-red-500/20 text-red-700 dark:text-red-300 font-bold hover:bg-red-500/30"
|
||||
}}
|
||||
disabled={(date) => date < new Date(new Date().setHours(0, 0, 0, 0))}
|
||||
data-testid="calendar-picker"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{value && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setSelectedDate(undefined);
|
||||
onChange(undefined);
|
||||
}}
|
||||
className="shrink-0"
|
||||
data-testid="button-clear-date"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedDate && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start text-left font-normal"
|
||||
data-testid="button-time-picker"
|
||||
>
|
||||
<Clock className="mr-2 h-4 w-4" />
|
||||
{String(selectedHour).padStart(2, '0')}:{String(selectedMinute).padStart(2, '0')}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<div className="flex">
|
||||
<ScrollArea className="h-60">
|
||||
<div className="p-2">
|
||||
<div className="text-xs font-semibold mb-2 px-2 text-muted-foreground">Heures</div>
|
||||
{hours.map((hour) => (
|
||||
<Button
|
||||
key={hour}
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"w-full justify-center mb-1",
|
||||
selectedHour === hour && "bg-primary text-primary-foreground",
|
||||
isOptimalHour(hour) && selectedHour !== hour && "bg-green-500/20 text-green-700 dark:text-green-300 font-semibold hover:bg-green-500/30"
|
||||
)}
|
||||
onClick={() => handleTimeChange(hour, selectedMinute)}
|
||||
data-testid={`hour-${hour}`}
|
||||
>
|
||||
{String(hour).padStart(2, '0')}h
|
||||
{isOptimalHour(hour) && selectedHour !== hour && (
|
||||
<span className="ml-1 text-[10px]">✨</span>
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<ScrollArea className="h-60 border-l">
|
||||
<div className="p-2">
|
||||
<div className="text-xs font-semibold mb-2 px-2 text-muted-foreground">Minutes</div>
|
||||
{minutes.map((minute) => (
|
||||
<Button
|
||||
key={minute}
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"w-full justify-center mb-1",
|
||||
selectedMinute === minute && "bg-primary text-primary-foreground"
|
||||
)}
|
||||
onClick={() => handleTimeChange(selectedHour, minute)}
|
||||
data-testid={`minute-${minute}`}
|
||||
>
|
||||
{String(minute).padStart(2, '0')}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<div className="p-3 border-t bg-muted/50">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500"></div>
|
||||
<span className="text-[10px] font-semibold text-green-600 dark:text-green-400">
|
||||
Heures optimales (8h, 12h-13h, 18h-20h)
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[9px] text-muted-foreground">
|
||||
Ces créneaux maximisent l'engagement
|
||||
</p>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
{occupiedDates.length > 0 && (
|
||||
<div className="p-3 bg-red-500/10 rounded-lg border border-red-500/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-red-500"></div>
|
||||
<span className="text-xs font-semibold text-red-600 dark:text-red-400">
|
||||
Les dates en fond rouge ont déjà des publications programmées
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import type { SocialPage, Media, ScheduledPost } from "@shared/schema";
|
||||
import { PreviewModal } from "@/components/preview-modal";
|
||||
import { DateTimePicker } from "@/components/datetime-picker";
|
||||
|
||||
function SortableMediaItem({
|
||||
media,
|
||||
@@ -108,7 +109,7 @@ export default function NewPost() {
|
||||
const [productInfo, setProductInfo] = useState('');
|
||||
const [selectedMedia, setSelectedMedia] = useState<string[]>([]);
|
||||
const [selectedPages, setSelectedPages] = useState<string[]>([]);
|
||||
const [scheduledDate, setScheduledDate] = useState('');
|
||||
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
|
||||
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
|
||||
const [postText, setPostText] = useState('');
|
||||
const [postType, setPostType] = useState<'feed' | 'story' | 'both'>('feed');
|
||||
@@ -280,7 +281,7 @@ export default function NewPost() {
|
||||
|
||||
createPostMutation.mutate({
|
||||
content: postText,
|
||||
scheduledFor: scheduledDate ? new Date(scheduledDate).toISOString() : undefined,
|
||||
scheduledFor: scheduledDate ? scheduledDate.toISOString() : undefined,
|
||||
mediaIds: selectedMedia.length > 0 ? selectedMedia : undefined,
|
||||
pageIds: selectedPages,
|
||||
postType,
|
||||
@@ -712,91 +713,18 @@ export default function NewPost() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="scheduledDate">Date et heure</Label>
|
||||
<Input
|
||||
id="scheduledDate"
|
||||
type="datetime-local"
|
||||
<Label>Date et heure</Label>
|
||||
<DateTimePicker
|
||||
value={scheduledDate}
|
||||
onChange={(e) => setScheduledDate(e.target.value)}
|
||||
data-testid="input-scheduled-date"
|
||||
onChange={setScheduledDate}
|
||||
occupiedDates={scheduledPosts
|
||||
.filter(post => post.scheduledAt)
|
||||
.map(post => new Date(post.scheduledAt!))}
|
||||
placeholder="Publier immédiatement"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Laissez vide pour publier immédiatement
|
||||
Sélectionnez une date pour planifier, sinon publication immédiate
|
||||
</p>
|
||||
|
||||
{(() => {
|
||||
// Get dates with scheduled posts
|
||||
const datesWithPosts = scheduledPosts
|
||||
.filter(post => post.scheduledAt)
|
||||
.map(post => {
|
||||
const date = new Date(post.scheduledAt!);
|
||||
return date.toISOString().split('T')[0];
|
||||
})
|
||||
.filter((date, index, self) => self.indexOf(date) === index)
|
||||
.sort();
|
||||
|
||||
if (datesWithPosts.length > 0) {
|
||||
return (
|
||||
<div className="p-3 bg-red-500/10 rounded-lg border border-red-500/20">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="w-2 h-2 rounded-full bg-red-500"></div>
|
||||
<span className="text-xs font-semibold text-red-600 dark:text-red-400">
|
||||
Dates avec publications programmées
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{datesWithPosts.slice(0, 5).map((date) => {
|
||||
const dateObj = new Date(date + 'T00:00:00');
|
||||
const formatted = dateObj.toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: 'short'
|
||||
});
|
||||
return (
|
||||
<span
|
||||
key={date}
|
||||
className="text-xs px-2 py-1 bg-red-500/20 text-red-700 dark:text-red-300 rounded-md font-medium"
|
||||
>
|
||||
{formatted}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{datesWithPosts.length > 5 && (
|
||||
<span className="text-xs px-2 py-1 text-red-600 dark:text-red-400 font-medium">
|
||||
+{datesWithPosts.length - 5} autre{datesWithPosts.length - 5 > 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] text-red-600/80 dark:text-red-400/80 mt-2">
|
||||
Ces dates ont déjà des publications planifiées
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
|
||||
<div className="p-3 bg-green-500/10 rounded-lg border border-green-500/20">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500"></div>
|
||||
<span className="text-xs font-semibold text-green-600 dark:text-green-400">
|
||||
Heures optimales pour l'audience
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="text-xs px-2 py-1 bg-green-500/20 text-green-700 dark:text-green-300 rounded-md font-medium">
|
||||
8h - 9h
|
||||
</span>
|
||||
<span className="text-xs px-2 py-1 bg-green-500/20 text-green-700 dark:text-green-300 rounded-md font-medium">
|
||||
12h - 14h
|
||||
</span>
|
||||
<span className="text-xs px-2 py-1 bg-green-500/20 text-green-700 dark:text-green-300 rounded-md font-medium">
|
||||
18h - 21h
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-green-600/80 dark:text-green-400/80 mt-2">
|
||||
Ces créneaux maximisent la portée de vos publications
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
Reference in new issue
Block a user