Merge pull request #82 from R0m1k3/dev

Dev
This commit is contained in:
LogiFlow authored and GitHub committed 2025-10-28 14:12:09 +01:00
commit 7aff03a753
3 files changed
+222 -84

No files matched your search

+210
View File
@@ -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>
);
}
+11 -83
View File
@@ -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>
+1 -1
View File
@@ -11,7 +11,7 @@ Preferred communication style: Simple, everyday language.
## Recent Changes
### October 28, 2025
- **Calendar Mobile UX Improvements**: Enhanced mobile calendar and publication planning interface with three visual improvements: (1) Mobile calendar list now displays today's date at the top, before chronological ordering, for quick access to current day's publications; (2) Added green visual indicators in new-post page showing optimal posting times (8h-9h, 12h-14h, 18h-21h) to maximize audience reach; (3) Added red visual indicators showing dates with existing scheduled posts to prevent scheduling conflicts. Both indicators appear dynamically below the datetime selector, displaying up to 5 dates with option to view more.
- **Interactive Calendar with Visual Indicators**: Replaced native datetime-local input with custom DateTimePicker component featuring visual planning aids. Calendar displays occupied dates with red background (bg-red-500/20) to prevent scheduling conflicts. Time selector highlights optimal posting hours (8h, 12h-13h, 18h-20h) with green background (bg-green-500/20) and sparkle emoji to maximize audience engagement. Component includes clear button (X icon) to quickly return to immediate publishing, useEffect synchronization for external state updates, and French locale support via date-fns. Mobile calendar list still displays today's date first for quick access.
- **Facebook Video Story Publishing Fix**: Fixed critical bug preventing video story publication. Facebook Graph API requires a 3-phase upload process for video stories: (1) START phase - initialize upload session and get `video_id` + `upload_url`, (2) UPLOAD phase - POST video to rupload.facebook.com endpoint with `file_url` header and OAuth authorization, (3) FINISH phase - finalize and publish story with `video_id`. Previous implementation incorrectly attempted direct upload to `/video_stories` endpoint with `file_url` parameter, causing "#100 The parameter upload_phase is required" error. Fixed by implementing proper 3-phase workflow using remote URL upload method (file_url header), avoiding need for binary chunked uploads.
### October 17, 2025