diff --git a/client/src/components/datetime-picker.tsx b/client/src/components/datetime-picker.tsx new file mode 100644 index 0000000..9f1f009 --- /dev/null +++ b/client/src/components/datetime-picker.tsx @@ -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(value); + const [selectedHour, setSelectedHour] = useState(value?.getHours() || 8); + const [selectedMinute, setSelectedMinute] = useState(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 ( +
+
+ + + + + + 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" + /> + + + {value && ( + + )} +
+ + {selectedDate && ( + + + + + +
+ +
+
Heures
+ {hours.map((hour) => ( + + ))} +
+
+ +
+
Minutes
+ {minutes.map((minute) => ( + + ))} +
+
+
+
+
+
+ + Heures optimales (8h, 12h-13h, 18h-20h) + +
+

+ Ces créneaux maximisent l'engagement +

+
+
+
+ )} + + {occupiedDates.length > 0 && ( +
+
+
+ + Les dates en fond rouge ont déjà des publications programmées + +
+
+ )} +
+ ); +} diff --git a/client/src/pages/new-post.tsx b/client/src/pages/new-post.tsx index 7a1cdb2..d817c7c 100644 --- a/client/src/pages/new-post.tsx +++ b/client/src/pages/new-post.tsx @@ -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([]); const [selectedPages, setSelectedPages] = useState([]); - const [scheduledDate, setScheduledDate] = useState(''); + const [scheduledDate, setScheduledDate] = useState(undefined); const [generatedVariants, setGeneratedVariants] = useState([]); 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() {
- - Date et heure + 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" />

- Laissez vide pour publier immédiatement + Sélectionnez une date pour planifier, sinon publication immédiate

- - {(() => { - // 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 ( -
-
-
- - Dates avec publications programmées - -
-
- {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 ( - - {formatted} - - ); - })} - {datesWithPosts.length > 5 && ( - - +{datesWithPosts.length - 5} autre{datesWithPosts.length - 5 > 1 ? 's' : ''} - - )} -
-

- Ces dates ont déjà des publications planifiées -

-
- ); - } - return null; - })()} - -
-
-
- - Heures optimales pour l'audience - -
-
- - 8h - 9h - - - 12h - 14h - - - 18h - 21h - -
-

- Ces créneaux maximisent la portée de vos publications -

-