import Card from "@app/components/card/Card"; import ContentCard from "@app/components/content-card/ContentCard"; import { formatDate, mapMediaToIcon, replaceNewLinesWithSpace, } from "@app/services/utilities"; import React, { useEffect, useRef, useState } from "react"; import "./grid-manager-styles.scss"; import { postsRefresh, postsManage, searchWPPosts, getDataFromURL, configSave, syncPosts, saveConfig, linkPost, unlinkPost, pinPost, unpinPost, } from "@app/services/api"; import { useSettings } from "@settings/contexts/SettingsContext"; import { useViewMode } from "@settings/contexts/ViewModeContext"; import { GetDataFromURLPayload, PostsManagePayload, Post, PostsRefreshPayload, WPDataItem, WPPost, PostsRefreshResponse, ConfigSavePayload, } from "@app/models/api"; import { EmptySearchIcon, ForwardIcon, LinkIcon, LockIcon, PinIcon, RefreshIcon, } from "@app/assets/images/icons"; import Skeleton from "@app/components/skeleton/Skeleton"; import { ButtonProps, CardHeaderProps, OptionProps, } from "@app/models/components"; import { Oval } from "react-loader-spinner"; import Modal from "@app/components/modal/Modal"; import Search from "@app/components/search/Search"; import Select from "@app/components/select/Select"; import { SfSyncConfigList, SfSyncSettings, LinkStatusFilter, MediaTypeFilter, } from "@app/models/global"; import EmptyState from "@app/components/EmptyState/EmptyState"; import { useGridManager } from "@settings/contexts/GridManagerContext"; import CardContent from "@app/components/card-content/CardContent"; import CardHeader from "@app/components/card-header/CardHeader"; import InfiniteScroll from "@app/components/InfiniteScroll/InfiniteScroll"; import Button from "@app/components/button/Button"; import { useNavigate } from "react-router-dom"; import CTACard from "@app/components/CTACard/CTACard"; import Tag from "@app/components/tag/tag"; import noConfigAnimation from "@app/assets/lotties/NoConfigurations.json"; import Lottie from "react-lottie"; import { isEqual } from "lodash"; import { linkStatusOptions, mediaTypeOptions } from "@app/constants"; import UpgradePopover from "@app/components/UpgradePopover/UpgradePopover"; import FeatureLockedContent from "@app/components/UpgradePopover/FeatureLockedContent"; import { ActivePopover, useUpgradePopover, } from "@settings/contexts/UpgradePopoverContext"; import { mediaLimitOptions } from "@app/constants"; const GridManager = () => { const [isFetching, setIsFetching] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false); const [isLinking, setIsLinking] = useState(false); const [isUnlinking, setIsUnlinking] = useState(false); const [isSearching, setIsSearching] = useState(false); const [isPinning, setIsPinning] = useState(false); const [showEmptyState, setShowEmptyState] = useState(false); const [config, setConfig] = useState({}); const [posts, setPosts] = useState>([]); const [filteredPosts, setFilteredPosts] = useState>([]); const [isTwoColView, setIsTwoColView] = useState(false); const [openLinkModal, setLinkOpenModal] = useState(false); const [openUnlinkModal, setUnlinkOpenModal] = useState(false); const [openPinModal, setPinOpenModal] = useState(false); const [postsData, setPostsData] = useState>({}); const [searchTerm, setSearchTerm] = useState(""); const [searchedPosts, setSearchedPosts] = useState>([]); const [selectedWPPost, setSelectedWPPost] = useState({ id: 0, title: "", url: "", pubDate: "", }); const [popover, setPopover] = useState(""); const [searchOptions, setSearchOptions] = useState>([]); const [selectedIGPostId, setSelectedIGPostId] = useState(""); const [isSaving, setIsSaving] = useState(false); const localization = sfsyncI18n.data; const { activePopover, setActivePopover } = useUpgradePopover(); const limitRef = useRef(null); const postsGridRef = useRef(null); const { is_premium: isPremium } = sfsyncData; const defaultLottieOptions = { loop: true, autoplay: true, animationData: noConfigAnimation, rendererSettings: { preserveAspectRatio: "xMidYMid slice", }, }; const handleConfigSave = async (media_limit: number): Promise => { const configSavePayload: ConfigSavePayload = { instagram_id: initialSettings?.selected_config ?? "", media_limit: media_limit, }; let apiKey = initialSettings.api_key ?? ""; if (configSavePayload.media_limit === 0) { configSavePayload.media_limit = 3; } const configSaveResp = await configSave(apiKey, configSavePayload); if (configSaveResp?.error) { return false; } return true; }; const handleUpdateSettings = async ( media_limit: number ): Promise => { const selectedConfig = initialSettings.selected_config ?? ""; const resp = await saveConfig(selectedConfig, { media_limit }); if (resp?.error) { return false; } setInitialSettings({ ...initialSettings, config_list: { ...initialSettings.config_list, [selectedConfig]: { ...initialSettings.config_list?.[selectedConfig], media_limit, }, }, }); return true; }; const handleNumberInputChange = async (name: string, value: string) => { if (!isPremium && value !== "3") { setActivePopover(ActivePopover.Limit); return; } if (config[name] == value) { return; } setIsSaving(true); /** * Call /config/save */ const configSaveSuccess = await handleConfigSave(parseInt(value)); if (!configSaveSuccess) { setIsSaving(false); return; } /** * Save settings */ const saveConfigSuccess = await handleUpdateSettings(parseInt(value)); if (!saveConfigSuccess) { showNotification("error", localization.save_config_error_message, toastPosition); setIsSaving(false); return; } setIsSaving(false); setConfig({ ...config, [name]: value }); }; const { isRTL } = useViewMode(); const { initialSettings, setInitialSettings, showNotification, emptyConfigList, } = useSettings(); const { filters, setFilters } = useGridManager(); const toastPosition = isRTL ? "top-left" : "top-right"; const navigate = useNavigate(); useEffect(() => { if (emptyConfigList) { navigate("/configuration", { replace: true }); } }, [emptyConfigList]); useEffect(() => { const handleResize = () => { if (postsGridRef.current) { setIsTwoColView(postsGridRef.current.clientWidth <= 650); } }; handleResize(); // Initial check on component mount const resizeObserver = new ResizeObserver(handleResize); if (postsGridRef.current) { resizeObserver.observe(postsGridRef.current); } return () => { if (postsGridRef.current) { resizeObserver.unobserve(postsGridRef.current); } }; }, []); useEffect(() => { if (!posts.length) return; handleRefreshPosts(); }, [initialSettings.selected_config]); useEffect(() => { if (emptyConfigList || !initialSettings.selected_config) { return; } let selectedConfig = initialSettings?.config_list?.[initialSettings.selected_config ?? ""]; setConfig(selectedConfig); let showSkeleton = true; if (selectedConfig?.posts && !!selectedConfig.posts.length) { setPosts(selectedConfig.posts); showSkeleton = false; } handleGetPosts(showSkeleton); }, [emptyConfigList, initialSettings]); useEffect(() => { if (!posts.length) return; let postsToDisplay: Post[] = posts; if (filters.link_status || filters.media_type) { const mediaTypeFilters = ( filters.media_type ? filters.media_type.split(",") : [] ) as MediaTypeFilter[]; const linkStatusFilters = ( filters.link_status ? filters.link_status.split(",") : [] ) as LinkStatusFilter[]; if (mediaTypeFilters.length) { postsToDisplay = postsToDisplay.filter((post) => mediaTypeFilters.find((mediaType) => post?.media_type === mediaType), ); } if (linkStatusFilters.length) { postsToDisplay = postsToDisplay.filter((post) => linkStatusFilters.find((linkStatus) => { if ( (linkStatus === "linked" && post?.link) || (linkStatus === "unlinked" && !post?.link) ) { return true; } return false; }), ); } } const pinnedPosts = postsToDisplay.filter((post) => post.pinned === true); const unpinnedPosts = postsToDisplay.filter((post) => post.pinned !== true); postsToDisplay = [...pinnedPosts, ...unpinnedPosts]; setFilteredPosts(postsToDisplay); }, [posts, filters]); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { const isNotInPopover = !(event.target as HTMLElement).classList.contains( "sfsyncGridManager-gridItem-popoverContent" ); if (isNotInPopover) { setPopover(""); } }; document.addEventListener("mousedown", handleClickOutside); return () => { document.removeEventListener("mousedown", handleClickOutside); }; }, [setPopover]); const clearSelectedWPPost = () => { setSelectedWPPost({ id: 0, title: "", url: "", pubDate: "", }); }; const handlePostsRefresh = async (limit?: number) => { const selectedConfig = initialSettings.selected_config ?? ""; const apiKey = initialSettings.api_key ?? ""; // Skip If-Modified-Since on initial mount so remounting always loads fresh // posts from the external API. On subsequent refreshes the header is sent // and the API may return 304 (posts unchanged). const lastModified = initialSettings?.config_list?.[selectedConfig]?.last_modified ?? ""; const refreshPostsPayload: PostsRefreshPayload = { instagram_id: selectedConfig, }; const resp = await postsRefresh(apiKey, refreshPostsPayload, lastModified); if ("error" in resp && resp.error) { setIsFetching(false); setIsRefreshing(false); setShowEmptyState(true); return; } if (resp.status === 304) { // Posts unchanged — keep the current `posts` state as-is. setIsFetching(false); setIsRefreshing(false); return; } const postsRefreshResponse = resp as PostsRefreshResponse; setShowEmptyState(false); // Persist Instagram post metadata to wp_sfsync_posts. const syncPostsResponse = await syncPosts({ instagram_id: selectedConfig, posts: postsRefreshResponse.posts, }); if(syncPostsResponse?.error) { showNotification("error", localization.sync_posts_error_message, toastPosition); setIsFetching(false); setIsRefreshing(false); return; } if ( postsRefreshResponse.lastModified && selectedConfig && initialSettings.config_list ) { const lastModifiedDate = new Date(postsRefreshResponse.lastModified); lastModifiedDate.setSeconds(lastModifiedDate.getSeconds() + 1); const updatedLastModified = lastModifiedDate.toUTCString(); const updatedConfig = { ...initialSettings.config_list[selectedConfig], last_modified: updatedLastModified, ...(limit !== undefined ? { media_limit: limit } : {}), }; // Persist last_modified (and optional media_limit) to wp_sfsync_configs. const saveConfigResp = await saveConfig(selectedConfig, updatedConfig); if (saveConfigResp?.error) { showNotification("error", localization.save_config_error_message, toastPosition); setIsFetching(false); setIsRefreshing(false); return; } //save posts to the config list in settings context so that it can be used for initial render without refetching from API const updatedConfigWithPosts = { ...updatedConfig, posts: postsRefreshResponse.posts, }; setInitialSettings({ ...initialSettings, config_list: { ...initialSettings.config_list, [selectedConfig]: updatedConfigWithPosts, }, }); } const urlMap: Record = {}; postsRefreshResponse.posts.forEach((post) => { if (post.id && post.link) { urlMap[post.id] = post.link; } }); handleGetPostsData(urlMap); setPosts(postsRefreshResponse.posts); }; const handleGetPosts = async (showSkeleton = true) => { if (showSkeleton) { setIsFetching(true); } await handlePostsRefresh(); setIsFetching(false); }; const handleGetPostsData = async (urlMap: Record) => { if (Object.keys(urlMap).length === 0) return; const selectedConfig = initialSettings.selected_config ?? ""; const payload: GetDataFromURLPayload = { urls: urlMap, }; const currentLanguage = sfsyncData.languages.length > 0 ? initialSettings.config_list?.[selectedConfig]?.language : ""; if (currentLanguage) { payload.language = currentLanguage; } const postData = await getDataFromURL(payload); if ("error" in postData) { setPostsData({}); } else { setPostsData(postData); } }; const handleRefreshPosts = async (limit?: number) => { setIsRefreshing(true); await handlePostsRefresh(limit); setIsRefreshing(false); }; const onClearFilter = (filter: "media_type" | "link_status") => { setFilters({ ...filters, [filter]: "", }); }; const handleFilterChange = (name: string, value: string) => { setFilters({ ...filters, [name]: value }); }; const selectedConfigKey = initialSettings.selected_config; const selectedConfigObject = selectedConfigKey ? initialSettings.config_list?.[selectedConfigKey] : undefined; const selectedConfigName = selectedConfigObject?.username ? selectedConfigObject.username : selectedConfigObject?.name ? selectedConfigObject?.name : ""; const headerPropsTitle = selectedConfigName ? `${localization.grid_manager_for} ${selectedConfigName}` : localization.grid_manager; const headerProps: CardHeaderProps = { title: headerPropsTitle, subtitle: localization.grid_manager_subtitle, actionButtons: [ { text: localization.refresh, type: "button", icon: RefreshIcon, variant: "secondary", shape: "rounded", disabled: isFetching || isRefreshing || !posts.length || isSaving, onClickHandler: () => { void handleRefreshPosts(); }, size: "lg", }, ], }; const overlayContent = (
{LinkIcon}

{localization.linked}

); const handleSearchItem = (name: string, value: string) => { setIsSearching(true); setSearchTerm(value); clearSelectedWPPost(); }; useEffect(() => { const searchPosts = async () => { const selectedConfig = initialSettings.selected_config ?? ""; let selectedConfigLanguage = ""; if (sfsyncData.languages.length) { selectedConfigLanguage = initialSettings.config_list?.[selectedConfig]?.language ?? ""; } let currentId = initialSettings?.selected_config || ""; let currentScope: string = initialSettings?.config_list?.[currentId]?.scope || ""; let searchPostsResponse = await searchWPPosts( searchTerm, selectedConfigLanguage, currentScope ); if ("error" in searchPostsResponse) { showNotification( "error", `${localization.retrieve_post_message} (${localization.error} ${searchPostsResponse.status} )`, toastPosition ); setIsSearching(false); return; } setSearchedPosts(searchPostsResponse); setIsSearching(false); }; //debounce the API call to prevent requests on each keystroke const debounceTimer = setTimeout(() => { searchPosts(); }, 500); return () => clearTimeout(debounceTimer); }, [searchTerm]); useEffect(() => { setSearchOptions( searchedPosts.map((ele) => ({ value: ele.url, text: ele.title, onClick: handleSelectItem, meta: (

{formatDate(ele.pubDate)}

{}
), action: { text: "View", url: ele.url, }, })) ); }, [searchedPosts]); const handleSelectItem = (url: string) => { let selected = searchedPosts.find((ele) => ele.url == url); if (selected) { setSelectedWPPost(selected); } else { clearSelectedWPPost(); } setSearchedPosts([]); }; const handleLinkModalClose = () => { setLinkOpenModal(false); setSearchTerm(""); clearSelectedWPPost(); }; const handlePinModalClose = () => { setPinOpenModal(false); }; const handlePinButtonClick = (post: Post) => { setPinOpenModal(true); setSelectedIGPostId(post.id); }; const handleUnlinkModalClose = () => { setUnlinkOpenModal(false); }; const handleLinkButtonClick = (post: Post) => { const linkedPostsCount = posts.filter((post) => post.link).length; if (!isPremium && linkedPostsCount >= 3) { setPopover(post.id); return; } setLinkOpenModal(true); setSelectedIGPostId(post.id); }; const handleUnlinkButtonClick = (post: Post) => { setUnlinkOpenModal(true); setSelectedIGPostId(post.id); }; const handlePost = async (action: "link" | "unlink") => { setIsLinking(true); setIsUnlinking(true); const selectedConfig = initialSettings.selected_config ?? ""; const apiKey = initialSettings.api_key ?? ""; const postsManagePayload: PostsManagePayload = { action, instagram_id: selectedConfig, post_id: selectedIGPostId, link: selectedWPPost.url, return_linked: true, }; const externalResp = await postsManage(apiKey, postsManagePayload); if (externalResp?.error) { setIsLinking(false); setIsUnlinking(false); handleLinkModalClose(); handleUnlinkModalClose(); return; } // Persist the link/unlink to wp_sfsync_posts. if (action === "link") { const wpResp = await linkPost(selectedIGPostId, { instagram_id: selectedConfig, wp_url: selectedWPPost.url, }); if (wpResp?.error) { showNotification("error", localization.link_post_error_message, toastPosition); setIsLinking(false); setIsUnlinking(false); handleLinkModalClose(); return; } } else { const wpResp = await unlinkPost(selectedIGPostId, { instagram_id: selectedConfig, }); if (wpResp?.error) { showNotification("error", localization.unlink_post_error_message, toastPosition); setIsLinking(false); setIsUnlinking(false); handleUnlinkModalClose(); return; } } // Update posts state from the external API response. const newPostsMap = new Map( externalResp.posts.map((post: any) => [post.id, post]), ); const updatedPosts = posts.map((post: any) => { if (newPostsMap.has(post.id)) { return newPostsMap.get(post.id); } const { link, ...rest } = post; return { ...rest, pinned: false }; }); const uniquePosts = Array.from( new Map(updatedPosts.map((post) => [post.id, post])).values(), ); if (action === "link") { const urlMap: Record = {}; uniquePosts.forEach((post) => { if (post.id && post.link) urlMap[post.id] = post.link; }); handleGetPostsData(urlMap); } setPosts(uniquePosts); setInitialSettings({ ...initialSettings, config_list: { ...initialSettings.config_list, [selectedConfig]: { ...initialSettings.config_list?.[selectedConfig], posts: uniquePosts, }, }, }); setIsLinking(false); setIsUnlinking(false); handleLinkModalClose(); handleUnlinkModalClose(); }; const handlePinPost = async (post: Post, action: "pin" | "unpin") => { setIsPinning(true); setSelectedIGPostId(post.id); const apiKey = initialSettings.api_key ?? ""; const selectedConfig = initialSettings.selected_config ?? ""; const externalPayload: PostsManagePayload = { action, instagram_id: selectedConfig, post_id: post.id, return_linked: true, }; const externalResp = await postsManage(apiKey, externalPayload); if (externalResp?.error) { setIsPinning(false); return; } // Persist pin/unpin to wp_sfsync_posts. if (action === "pin") { const wpResp = await pinPost(post.id, { instagram_id: selectedConfig, }); if (wpResp?.error) { showNotification("error", localization.pin_post_error_message, toastPosition); setIsPinning(false); return; } } else { const wpResp = await unpinPost(post.id, { instagram_id: selectedConfig, }); if (wpResp?.error) { showNotification("error", localization.unpin_post_error_message, toastPosition); setIsPinning(false); return; } } // Flip the pinned flag locally — no need to rebuild from the external API // response since pin/unpin only changes wp_sfsync_posts.pinned, not the // linked post list. const updatedPosts = posts.map((p) => p.id === post.id ? { ...p, pinned: action === "pin" } : p, ); setPosts(updatedPosts); setInitialSettings({ ...initialSettings, config_list: { ...initialSettings.config_list, [selectedConfig]: { ...initialSettings.config_list?.[selectedConfig], posts: updatedPosts, }, }, }); setIsPinning(false); setSelectedIGPostId(""); }; const countPinnedPosts = () => { return filteredPosts.filter((post) => post.pinned).length; }; const handleReplacePinPost = async () => { const pinnedPosts = filteredPosts.filter((post) => post.pinned); const oldestPinnedPost = pinnedPosts[0]; await handlePinPost(oldestPinnedPost, "unpin"); const selectedPost = filteredPosts.find( (post) => post.id === selectedIGPostId ); if (selectedPost) { await handlePinPost(selectedPost, "pin"); } handlePinModalClose(); }; const skeletonItems = Array.from({ length: 6 }, (_, index) => index + 1); const getActionButtons = (post: Post) => { let lockIcon = false; const linkedPostsCount = posts.filter((post) => post.link).length; if (!isPremium && linkedPostsCount >= 3 && !post.link) { lockIcon = true; } const buttons: ButtonProps[] = [ { type: "button", text: post.link ? localization.unlink : localization.link, icon: lockIcon ? LockIcon : null, onClickHandler: () => { if (post.link) { handleUnlinkButtonClick(post); } else { handleLinkButtonClick(post); } }, }, ]; if (post?.link) { const isPinned = post?.pinned; const pinCountUnderThree = !isPinned && countPinnedPosts() < 3; const isLoading = isPinning && selectedIGPostId === post.id; buttons.push({ type: "button", text: isPinned ? localization.unpin : localization.pin, onClickHandler: () => { isPinned ? handlePinPost(post, "unpin") : pinCountUnderThree ? handlePinPost(post, "pin") : handlePinButtonClick(post); }, isLoading: isLoading, }); } return buttons; }; const renderPost = (post: Post) => { return ( (post?.media_url || post?.thumbnail_url) && (
{popover === post.id && (
)} post?.link && window.open(post?.link, "_blank") } meta={ postsData[post.id]?.date ? formatDate( postsData[post.id].date, postsData[post.id].language ) : "" } scaleDown /> ) : null } />
) ); }; const goToConfigButtonProps: ButtonProps = { text: localization.go_to_configuration, type: "button", onClickHandler: () => { navigate("/configuration"); }, icon: ForwardIcon, iconPosition: "end", }; return (
{!emptyConfigList && initialSettings.selected_config && (
onClearFilter("link_status")} />
{!isPremium ? (
setActivePopover(ActivePopover.None)} content={
} > ) : null } disabled={isSaving} /> )}
)}
{!!posts.length && !filteredPosts.length && (filters.link_status || filters.media_type) && ( )} {emptyConfigList || !initialSettings.selected_config ? ( } title={localization.no_configuration_selected} subtitle={ sfsyncRestAPI?.isAdmin ? localization.select_config_subtitle : localization.contact_admin_subtitle } > {sfsyncRestAPI?.isAdmin && (
{ handlePost("link"); }, disabled: !selectedWPPost.title || isLinking, isLoading: isLinking, }, { type: "button", text: localization.cancel, onClickHandler: handleLinkModalClose, variant: "secondary", disabled: isLinking, }, ]} > { handlePost("unlink"); }, disabled: isUnlinking, isLoading: isUnlinking, isDestructive: true, }, { type: "button", text: localization.cancel, onClickHandler: handleUnlinkModalClose, variant: "secondary", disabled: isUnlinking, id: "sfsyncGridManager-modalCancelBtn", }, ]} >

{localization.unlink_post_confirmation}

{ handleReplacePinPost(); }, disabled: isPinning, isLoading: isPinning, }, { type: "button", text: localization.cancel, onClickHandler: handlePinModalClose, variant: "secondary", disabled: isPinning, id: "sfsyncGridManager-modalCancelBtn", }, ]} >

{localization.link_post_exceed_confirmation}

); }; export default GridManager;