import React, { useEffect, useMemo, useState } from 'react'; import { FaArrowDown, FaArrowUp, FaTimes } from 'react-icons/fa'; import type { ArticleSummary } from '../../service/visibility/visibility.interface'; import Datepicker from '../ui/Datepicker'; import Modal from './Modal'; /** Hour of day the queue publishes at when the merchant only picks a date. */ const DEFAULT_PUBLISH_HOUR = 9; /** Most articles one queue may hold; mirrors the agent's cap. */ const MAX_QUEUE_ARTICLES = 22; /** Saturday in ``Date.getDay()`` terms (Sunday is 0). */ const SATURDAY_INDEX = 6; /** Sunday in ``Date.getDay()`` terms. */ const SUNDAY_INDEX = 0; /** Props for {@link SchedulePublishModal}. */ interface SchedulePublishModalProps { open: boolean; onOpenChange: (next: boolean) => void; /** The articles the merchant picked, in the order they were selected. */ articles: ArticleSummary[]; /** Disable the controls while the queue is being created. */ busy?: boolean; /** * Days already chosen, parallel to ``articles``. Passed when editing an * existing queue entry so the calendar opens on the day the article is set * to, instead of proposing a fresh one. */ initialDates?: string[]; /** Overrides the confirm label, e.g. when changing an existing day. */ confirmLabel?: string; /** * Why the last attempt failed. Rendered INSIDE the modal, next to the * confirm button: this overlay covers the page, so anything reported behind * it stays invisible while the merchant is still looking at what failed. */ error?: string | null; /** Fired with the final order and the chosen day for each article. */ onConfirm: (articleIds: string[], publishDates: string[]) => void; } /** * Move a date to the next weekday when it lands on a Saturday or Sunday. * * @param date {Date} The candidate day. * @returns {Date} The same day, or the following Monday. */ const skipWeekend = (date: Date): Date => { const moved = new Date(date); while (moved.getDay() === SATURDAY_INDEX || moved.getDay() === SUNDAY_INDEX) { moved.setDate(moved.getDate() + 1); } return moved; }; /** * Build one publishing day per article, starting tomorrow. * * @param count {number} How many days to produce. * @param weekdaysOnly {boolean} Skip Saturday and Sunday. * @returns {Date[]} The days, in order. */ const buildDefaultDays = (count: number, weekdaysOnly: boolean): Date[] => { const days: Date[] = []; const cursor = new Date(); cursor.setHours(DEFAULT_PUBLISH_HOUR, 0, 0, 0); cursor.setDate(cursor.getDate() + 1); for (let index = 0; index < count; index += 1) { const day = weekdaysOnly ? skipWeekend(cursor) : new Date(cursor); days.push(new Date(day)); cursor.setTime(day.getTime()); cursor.setDate(cursor.getDate() + 1); } return days; }; /** * Bulk-schedule reviewed articles: order them, give each one a day, and let * the queue publish them live so the merchant never has to come back and * click publish every morning. * * @param props {SchedulePublishModalProps} Modal props. * @returns {JSX.Element} The modal. */ const SchedulePublishModal = ({ open, onOpenChange, articles, busy = false, initialDates, confirmLabel, error, onConfirm, }: SchedulePublishModalProps): JSX.Element => { const [ordered, setOrdered] = useState([]); const [days, setDays] = useState([]); const [weekdaysOnly, setWeekdaysOnly] = useState(true); // The parent rebuilds the ``articles`` array on every render, so the seed // keys off the ids instead - otherwise a re-render while the modal is open // would throw away the ordering the merchant just arranged. ``initialDates`` // is keyed the same way, for the same reason. const pickedIds = articles.map(article => article.article_id).join(','); const pickedDates = (initialDates ?? []).join(','); // Re-seed every time the modal opens so a previous selection never leaks in. useEffect(() => { if (!open) return; const picked = articles.slice(0, MAX_QUEUE_ARTICLES); setOrdered(picked); // Editing an existing queue entry: open on the day it is already set to. // A partial list of days would pair articles with the wrong dates, so it // only seeds when there is exactly one day per article. const seeded = (initialDates ?? []) .slice(0, picked.length) .map(one => new Date(one)); setDays( seeded.length === picked.length ? seeded : buildDefaultDays(picked.length, true) ); setWeekdaysOnly(true); }, [open, pickedIds, pickedDates]); // Today, not tomorrow: an article already queued for today has to be able to // show the day it is set to, and the calendar would otherwise refuse the day // it was just seeded with. const earliestDay = useMemo(() => { const date = new Date(); date.setHours(0, 0, 0, 0); return date; }, []); // Editing the day of one article already in a queue, rather than laying out // a fresh batch. The batch-only controls are hidden in that mode. const editingOneDay = Boolean(initialDates); /** * Swap an article with its neighbour so the merchant can decide which one * goes live first. * * @param index {number} Position of the article being moved. * @param direction {-1 | 1} -1 moves it earlier, 1 moves it later. * @returns {void} */ const move = (index: number, direction: -1 | 1): void => { const target = index + direction; if (target < 0 || target >= ordered.length) return; const nextOrdered = [...ordered]; [nextOrdered[index], nextOrdered[target]] = [ nextOrdered[target], nextOrdered[index], ]; setOrdered(nextOrdered); }; /** * Drop one article from the queue without leaving the modal. The remaining * articles keep the days already assigned to their positions. * * @param index {number} Position of the article being dropped. * @returns {void} */ const removeAt = (index: number): void => { setOrdered(ordered.filter((_, position) => position !== index)); setDays(days.filter((_, position) => position !== index)); }; /** * Pin one article to a specific day. The publishing hour is fixed, so only * the date part of the pick matters. * * @param index {number} Position of the article being dated. * @param date {Date | undefined} The day picked in the calendar. * @returns {void} */ const setDay = (index: number, date?: Date): void => { if (!date) return; const nextDays = [...days]; const withHour = new Date(date); withHour.setHours(DEFAULT_PUBLISH_HOUR, 0, 0, 0); nextDays[index] = withHour; setDays(nextDays); }; /** * Flip the weekday switch and lay the whole batch out again from tomorrow, * since keeping half-manual dates around would only confuse the order. * * @param nextWeekdaysOnly {boolean} Whether weekends must be skipped. * @returns {void} */ const respread = (nextWeekdaysOnly: boolean): void => { setWeekdaysOnly(nextWeekdaysOnly); setDays(buildDefaultDays(ordered.length, nextWeekdaysOnly)); }; const handleConfirm = (): void => { onConfirm( ordered.map(article => article.article_id), ordered.map((_, index) => (days[index] ?? new Date()).toISOString()) ); }; return ( onOpenChange(false)} title="Schedule publishing" size="lg" footer={ <> {error && (

{error}

)} } >

Pick the day each article goes live. They publish by themselves, in this order, so you only review once (always live, never as a draft).

{/* The spreader rebuilds every day from scratch, so in the single-article edit flow it could only ever discard the very day being edited. */} {!editingOneDay && (
Weekdays only Spread them Monday to Friday and skip the weekend.
)} {ordered.length === 0 ? (

Nothing selected. Pick the articles you want to schedule.

) : (
    {ordered.map((article, index) => { const label = article.title || article.article_id; return (
  • {index + 1} {label}
    setDay(index, dates[0])} />
    {/* Dropping the only row of a single-article edit reads as "unschedule this article", which it never was: it just empties the modal. */} {!editingOneDay && ( )}
  • ); })}
)} {articles.length > MAX_QUEUE_ARTICLES && (

{`Only the first ${MAX_QUEUE_ARTICLES} can go in one queue. The other ${ articles.length - MAX_QUEUE_ARTICLES } stay selected, so you can queue them straight after.`}

)}
); }; export default SchedulePublishModal;