From 479e83a8d28be336d54c51b1b40f0e2eb8f8bc74 Mon Sep 17 00:00:00 2001 From: Maithili Kalkar Date: Thu, 9 Jul 2026 16:31:39 -0400 Subject: [PATCH 1/5] fix: add pinterest autoposter and fix eslint errors for css --- src/components/Announcements/index.jsx | 4 + .../platforms/pinterest/Pinterest.module.css | 411 +++++++ .../platforms/pinterest/PinterestHelpers.js | 311 +++++ .../platforms/pinterest/index.jsx | 1004 +++++++++++++++++ 4 files changed, 1730 insertions(+) create mode 100644 src/components/Announcements/platforms/pinterest/Pinterest.module.css create mode 100644 src/components/Announcements/platforms/pinterest/PinterestHelpers.js create mode 100644 src/components/Announcements/platforms/pinterest/index.jsx diff --git a/src/components/Announcements/index.jsx b/src/components/Announcements/index.jsx index 4beda7de5c..2a1b57306f 100644 --- a/src/components/Announcements/index.jsx +++ b/src/components/Announcements/index.jsx @@ -19,6 +19,7 @@ import EmailPanel from './platforms/email'; import RedditAutoPoster from './platforms/reddit'; import SlashdotAutoPoster from './platforms/slashdot'; import SocialMediaComposer from './platforms/social/SocialMediaComposer'; +import PinterestPinComposer from './platforms/pinterest'; function Announcements({ title, email: initialEmail }) { const [activeTab, setActiveTab] = useState('email'); @@ -242,6 +243,9 @@ function Announcements({ title, email: initialEmail }) { case 'reddit': PlatformComposer = RedditAutoPoster; break; + case 'pinterest': + PlatformComposer = PinterestPinComposer; + break; default: PlatformComposer = SocialMediaComposer; } diff --git a/src/components/Announcements/platforms/pinterest/Pinterest.module.css b/src/components/Announcements/platforms/pinterest/Pinterest.module.css new file mode 100644 index 0000000000..8b0b036d1e --- /dev/null +++ b/src/components/Announcements/platforms/pinterest/Pinterest.module.css @@ -0,0 +1,411 @@ +/* ─── Pinterest Pin Composer ─────────────────────────────────────────────────── */ + +.pin-composer { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + color: #111827; + background: #f9fafb; + min-height: 100vh; + padding: 24px 16px; + box-sizing: border-box; +} + +.pin-composer.dark { + color: #f9fafb; + background: #111827; +} + +/* ─── Pane tabs ─────────────────────────────────────────────────────────────── */ + +.pin-panetabs { + display: flex; + gap: 4px; + margin-bottom: 20px; + border-bottom: 2px solid #e5e7eb; +} + +.pin-panetabs.dark { + border-bottom-color: #374151; +} + +.pin-panetab { + padding: 9px 18px; + font-size: 14px; + font-weight: 500; + border: none; + background: transparent; + cursor: pointer; + color: #6b7280; + border-bottom: 2px solid transparent; + margin-bottom: -2px; + transition: color 0.15s, border-color 0.15s; +} + +.pin-panetab:hover { + color: #e60023; +} + +.pin-panetab.active { + color: #e60023; + border-bottom-color: #e60023; + font-weight: 600; +} + +/* ─── Cards ─────────────────────────────────────────────────────────────────── */ + +.pin-card { + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 12px; + padding: 20px; + box-shadow: 0 1px 3px rgb(0 0 0 / 6%); +} + +.dark .pin-card { + background: #1f2937; + border-color: #374151; +} + +.pin-card.invalid { + border-color: #f87171; +} + +.dark .pin-card.invalid { + border-color: #dc2626; +} + +.pin-card h3 { + margin: 0 0 6px; + font-size: 18px; + font-weight: 700; +} + +.pin-card h4 { + margin: 0; + font-size: 15px; + font-weight: 600; +} + +.pin-card p { + margin: 0 0 10px; + font-size: 13.5px; + color: #6b7280; +} + +.dark .pin-card p { + color: #9ca3af; +} + +/* ─── Grid ───────────────────────────────────────────────────────────────────── */ + +.pin-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 16px; + margin-bottom: 20px; +} + +.pin-card--wide { + grid-column: 1 / -1; +} + +/* ─── Field header ───────────────────────────────────────────────────────────── */ + +.pin-field__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 6px; +} + +.pin-field__header label { + font-size: 13px; + font-weight: 600; + color: #374151; +} + +.pin-scheduler__field label { + font-size: 13px; + font-weight: 600; + color: #374151; +} + +.pin-card--queue-editor label { + font-size: 13px; + font-weight: 600; + color: #374151; + display: block; + margin-bottom: 6px; +} + +.dark .pin-field__header label { + color: #d1d5db; +} + +.dark .pin-card--queue-editor label { + color: #d1d5db; +} + +.pin-field__meta { + font-size: 12px; + color: #9ca3af; +} + +.pin-field__meta.invalid { + color: #ef4444; +} + +.pin-field__required { + color: #e60023; + margin-left: 2px; +} + +/* ─── Inputs ─────────────────────────────────────────────────────────────────── */ + +.pin-field__input { + width: 100%; + padding: 9px 11px; + border: 1px solid #d1d5db; + border-radius: 8px; + font-size: 14px; + box-sizing: border-box; + background: #fff; + color: #111827; + outline: none; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.pin-field__input:focus { + border-color: #e60023; + box-shadow: 0 0 0 3px rgb(230 0 35 / 12%); +} + +.dark .pin-field__input { + background: #111827; + border-color: #4b5563; + color: #f9fafb; +} + +.dark .pin-field__input:focus { + border-color: #e60023; +} + +.pin-field__input--invalid { + border-color: #f87171 !important; +} + +.pin-field__textarea { + resize: vertical; + min-height: 110px; + font-family: inherit; + line-height: 1.5; +} + +/* ─── Hints & errors ─────────────────────────────────────────────────────────── */ + +.pin-field__hint { + margin: 6px 0 0; + font-size: 12px; + color: #9ca3af; +} + +.pin-field__error { + margin: 6px 0 0; + font-size: 12px; + color: #ef4444; + font-weight: 500; +} + +/* ─── Preview ────────────────────────────────────────────────────────────────── */ + +.pin-preview__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 14px; + flex-wrap: wrap; + gap: 10px; +} + +.pin-preview__actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.pin-preview__body { + background: #f3f4f6; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 14px; + font-size: 13px; + white-space: pre-wrap; + word-break: break-all; + color: #374151; + min-height: 72px; + font-family: SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; +} + +.dark .pin-preview__body { + background: #111827; + border-color: #374151; + color: #d1d5db; +} + +.pin-preview__hint { + margin: 10px 0 0; + font-size: 12px; + color: #9ca3af; +} + +/* ─── Queue layout ───────────────────────────────────────────────────────────── */ + +.pin-queue__grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + align-items: start; +} + +@media (width <= 768px) { + .pin-queue__grid { + grid-template-columns: 1fr; + } +} + +/* ─── Scheduler field ────────────────────────────────────────────────────────── */ + +.pin-scheduler__field { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + min-width: 140px; +} + +.pin-queue__datetime-row { + display: flex; + gap: 14px; + margin-bottom: 14px; + flex-wrap: wrap; +} + +.pin-queue__textarea { + margin-top: 4px; + margin-bottom: 14px; + min-height: 140px; + resize: vertical; +} + +.pin-queue__action-row { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 4px; +} + +.pin-queue__edit-note { + font-size: 12.5px; + color: #6b7280; + background: #fff7ed; + border: 1px solid #fed7aa; + border-radius: 6px; + padding: 8px 12px; + margin-bottom: 14px !important; +} + +.dark .pin-queue__edit-note { + background: #1c1408; + border-color: #92400e; + color: #fcd34d; +} + +.pin-queue__loading { + font-size: 13px; + color: #9ca3af; + padding: 20px 0; + text-align: center; +} + +.pin-queue__empty { + font-size: 13px; + color: #9ca3af; + padding: 20px 0; + text-align: center; +} + +/* ─── Queue list ─────────────────────────────────────────────────────────────── */ + +.pin-queue__list { + display: flex; + flex-direction: column; + gap: 12px; + max-height: 520px; + overflow-y: auto; +} + +/* ─── Queue entry items ──────────────────────────────────────────────────────── */ + +.pin-entry__item { + border: 1px solid #e5e7eb; + border-radius: 10px; + padding: 14px; + background: #f9fafb; + transition: border-color 0.15s; +} + +.dark .pin-entry__item { + background: #111827; + border-color: #374151; +} + +.pin-entry__item--active { + border-color: #e60023; + background: #fff5f5; +} + +.dark .pin-entry__item--active { + border-color: #e60023; + background: #1a0a0a; +} + +.pin-entry__header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 8px; + margin-bottom: 6px; +} + +.pin-entry__title { + margin: 0; + font-size: 14px; + font-weight: 600; + color: #111827; +} + +.dark .pin-entry__title { + color: #f9fafb; +} + +.pin-entry__meta { + font-size: 11.5px; + color: #9ca3af; + white-space: nowrap; +} + +.pin-entry__excerpt { + font-size: 12.5px; + color: #6b7280; + margin: 0 0 10px; + line-height: 1.5; +} + +.dark .pin-entry__excerpt { + color: #9ca3af; +} + +.pin-entry__actions { + display: flex; + gap: 6px; + flex-wrap: wrap; +} \ No newline at end of file diff --git a/src/components/Announcements/platforms/pinterest/PinterestHelpers.js b/src/components/Announcements/platforms/pinterest/PinterestHelpers.js new file mode 100644 index 0000000000..b67ad019d1 --- /dev/null +++ b/src/components/Announcements/platforms/pinterest/PinterestHelpers.js @@ -0,0 +1,311 @@ +// ─── Pinterest field constants ───────────────────────────────────────────────── +export const PIN_TITLE_MIN = 2; +export const PIN_TITLE_MAX = 100; +export const PIN_DESC_MAX = 500; +export const PIN_NOTE_MAX = 500; // alt text cap + +// ─── String sanitizers ───────────────────────────────────────────────────────── + +/** + * Strips characters disallowed in Pinterest board names. + * Allows letters, digits, spaces, hyphens, and ampersands. + */ +export function sanitizeBoardName(raw) { + return raw.replace(/[^a-zA-Z0-9 \-&]/g, '').slice(0, 50); +} + +// ─── Preview builder ─────────────────────────────────────────────────────────── + +/** + * Builds a plain-text preview block for a Pinterest pin. + */ +export function buildPinPreview({ title, link, board, tag, description, alt }) { + const lines = []; + if (title?.trim()) lines.push(`📌 Title: ${title.trim()}`); + if (board?.trim()) lines.push(`📋 Board: ${board.trim()}`); + if (link?.trim()) lines.push(`🔗 Link: ${link.trim()}`); + if (tag?.trim()) lines.push(`🏷️ Tag: ${tag.trim()}`); + if (description?.trim()) lines.push(`\n📝 Description:\n${description.trim()}`); + if (alt?.trim()) lines.push(`\n♿ Alt text: ${alt.trim()}`); + return lines.join('\n'); +} + +// ─── Date / time formatters ──────────────────────────────────────────────────── + +/** Returns a local date string in YYYY-MM-DD format. */ +export function formatPinDate(dateObj) { + const y = dateObj.getFullYear(); + const m = String(dateObj.getMonth() + 1).padStart(2, '0'); + const d = String(dateObj.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; +} + +/** Returns a local time string in HH:MM format. */ +export function formatPinTime(dateObj) { + const h = String(dateObj.getHours()).padStart(2, '0'); + const min = String(dateObj.getMinutes()).padStart(2, '0'); + return `${h}:${min}`; +} + +/** Returns a human-readable "Month D, YYYY at HH:MM" string. */ +export function formatPinDisplayDateTime(dateStr, timeStr) { + if (!dateStr) return 'No date set'; + const [year, month, day] = dateStr.split('-').map(Number); + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + const base = `${months[month - 1]} ${day}, ${year}`; + return timeStr ? `${base} at ${timeStr}` : base; +} + +// ─── Schedule clamping ───────────────────────────────────────────────────────── + +/** + * Ensures the given date+time combo is not in the past. + * Returns a { date, time } object clamped to now if necessary. + */ +export function clampPinScheduleDateTime(dateStr, timeStr) { + const now = new Date(); + const todayStr = formatPinDate(now); + const currentTimeStr = formatPinTime(now); + + let clampedDate = dateStr; + let clampedTime = timeStr; + + if (dateStr < todayStr) { + clampedDate = todayStr; + clampedTime = currentTimeStr; + } else if (dateStr === todayStr && timeStr < currentTimeStr) { + clampedTime = currentTimeStr; + } + + return { date: clampedDate, time: clampedTime }; +} + +// ─── ID generator ───────────────────────────────────────────────────────────── + +/** Generates a unique pin queue entry ID. */ +export function generatePinId() { + return `pin_${Date.now()}_${Math.random() + .toString(36) + .slice(2, 8)}`; +} + +// ─── Tag suggestions ─────────────────────────────────────────────────────────── + +const PIN_KEYWORD_MAPS = { + diy: ['DIY', 'Handmade', 'Craft'], + recipe: ['Recipe', 'Food', 'Cooking'], + travel: ['Travel', 'Wanderlust', 'Adventure'], + fashion: ['Fashion', 'Style', 'Outfit'], + decor: ['Home Decor', 'Interior Design', 'Aesthetic'], + fitness: ['Fitness', 'Workout', 'Health'], + garden: ['Garden', 'Plants', 'Nature'], + beauty: ['Beauty', 'Skincare', 'Makeup'], + tech: ['Technology', 'Gadgets', 'Innovation'], + art: ['Art', 'Creative', 'Design'], + business: ['Business', 'Entrepreneurship', 'Marketing'], + kids: ['Parenting', 'Kids', 'Family'], +}; + +/** + * Returns up to 5 tag suggestions derived from pin title, description, and board. + */ +export function extractTagSuggestions(title = '', description = '', board = '') { + const combined = `${title} ${description} ${board}`.toLowerCase(); + const found = []; + for (const [keyword, tags] of Object.entries(PIN_KEYWORD_MAPS)) { + if (combined.includes(keyword)) found.push(...tags); + if (found.length >= 5) break; + } + return [...new Set(found)].slice(0, 5); +} + +// ─── Style helpers ───────────────────────────────────────────────────────────── + +/** Returns inline style for top-card action row. */ +export function pinTopCardActions() { + return { display: 'flex', gap: '8px', marginTop: '12px', flexWrap: 'wrap' }; +} + +/** Returns inline styles for a button variant. */ +export function pinButtonStyle(variant, isDark) { + const base = { + padding: '7px 14px', + borderRadius: '6px', + fontSize: '13px', + fontWeight: 500, + cursor: 'pointer', + border: 'none', + transition: 'opacity 0.15s', + }; + + if (variant === 'primary') { + return { ...base, background: '#e60023', color: '#fff' }; + } + if (variant === 'outline') { + return { + ...base, + background: 'transparent', + border: isDark ? '1px solid #555' : '1px solid #ccc', + color: isDark ? '#e5e7eb' : '#374151', + }; + } + // ghost + return { + ...base, + background: isDark ? '#1f2937' : '#f3f4f6', + color: isDark ? '#d1d5db' : '#374151', + }; +} + +/** Inline style for the field action button row. */ +export const pinFieldRow = { + display: 'flex', + gap: '6px', + marginTop: '10px', + flexWrap: 'wrap', +}; + +// ─── Backend API helpers ─────────────────────────────────────────────────────── +// Matches existing routes in src/routes/socialMediaRouter.js: +// POST /api/pinterest/createPin +// POST /api/pinterest/schedule +// GET /api/pinterest/schedule +// DELETE /api/pinterest/schedule/:id + +const PIN_BASE = '/api/social/pinterest'; +// helper to get token +function getAuthHeader() { + return { Authorization: localStorage.getItem('token') }; +} +/** + * Publishes a pin immediately to Pinterest via the backend. + * Calls POST /api/pinterest/createPin + * Backend saves nothing to DB — pin goes straight to Pinterest. + * + * @param {Object} pinRecord - { pinTitle, pinDescription, destinationLink, boardName, pinTag, altCaption, imageUrl, imgType, mediaItems } + * @returns {Promise} Pinterest API response from backend + */ +export async function publishPinNow(pinRecord) { + const response = await fetch(`${PIN_BASE}/createPin`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...getAuthHeader(), + }, + body: JSON.stringify({ + title: pinRecord.pinTitle, + description: pinRecord.pinDescription || '', + imgType: pinRecord.imgType || 'URL', + mediaItems: pinRecord.imageUrl ? { url: pinRecord.imageUrl } : pinRecord.mediaItems, + }), + }); + + if (!response.ok) { + const errorBody = await response.json().catch(() => ({})); + throw new Error(errorBody.error || `Publish failed (${response.status})`); + } + + return response.json(); +} + +/** + * Saves a pin to the schedule in MongoDB. + * Calls POST /api/pinterest/schedule + * The cron job (pinterestScheduleJob.js) picks it up and posts when scheduledTime arrives, + * then deletes the record from DB automatically. + * + * @param {Object} pinRecord - { pinTitle, pinDescription, imageUrl, imgType, mediaItems, scheduledDate, scheduledTime } + * @returns {Promise} + */ +export async function savePinToBackend(pinRecord) { + //const scheduledAt = `${pinRecord.scheduledDate}T${pinRecord.scheduledTime}:00.000Z` + + const response = await fetch(`${PIN_BASE}/schedule`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...getAuthHeader(), + }, + body: JSON.stringify({ + title: pinRecord.pinTitle, + description: pinRecord.pinDescription || '', + imgType: pinRecord.imgType || 'URL', + mediaItems: pinRecord.imageUrl ? { url: pinRecord.imageUrl } : pinRecord.mediaItems, + scheduledTime: `${pinRecord.scheduledDate}T${pinRecord.scheduledTime}`, + }), + }); + + if (!response.ok) { + throw new Error(`Schedule failed (${response.status})`); + } +} + +/** + * Fetches all scheduled pins from MongoDB. + * Calls GET /api/pinterest/schedule + * Note: published pins are deleted from DB by the cron job after posting, + * so this list only ever contains pending/future pins. + * + * @returns {Promise} Array of scheduled pin records + */ +export async function fetchSavedPins() { + const response = await fetch(`${PIN_BASE}/schedule`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + ...getAuthHeader(), + }, + }); + + if (!response.ok) { + throw new Error(`Fetch failed (${response.status})`); + } + + const data = await response.json(); + return data.map(entry => ({ + id: entry._id, + pinTitle: JSON.parse(entry.postData)?.title || 'Untitled', + draftText: entry.postData, + scheduledDate: entry.scheduledTime + ? new Date(entry.scheduledTime).toLocaleDateString('en-CA') // YYYY-MM-DD in local time + : '', + scheduledTime: entry.scheduledTime + ? `${String(new Date(entry.scheduledTime).getHours()).padStart(2, '0')}:${String( + new Date(entry.scheduledTime).getMinutes(), + ).padStart(2, '0')}` + : '', + })); +} + +/** + * Deletes a scheduled pin from MongoDB. + * Calls DELETE /api/pinterest/schedule/:id + * + * @param {string} pinEntryId - MongoDB _id of the scheduled pin + * @returns {Promise} + */ +export async function deletePinFromBackend(pinEntryId) { + const response = await fetch(`${PIN_BASE}/schedule/${pinEntryId}`, { + method: 'DELETE', + headers: { + ...getAuthHeader(), + }, + }); + + if (!response.ok) { + throw new Error(`Delete failed (${response.status})`); + } +} diff --git a/src/components/Announcements/platforms/pinterest/index.jsx b/src/components/Announcements/platforms/pinterest/index.jsx new file mode 100644 index 0000000000..b70d1c9d8d --- /dev/null +++ b/src/components/Announcements/platforms/pinterest/index.jsx @@ -0,0 +1,1004 @@ +import React, { useMemo, useState, useCallback } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { useSelector } from 'react-redux'; +import { toast } from 'react-toastify'; + +import pStyles from './Pinterest.module.css'; +import { + PIN_TITLE_MIN, + PIN_TITLE_MAX, + PIN_DESC_MAX, + PIN_NOTE_MAX, + sanitizeBoardName, + buildPinPreview, + formatPinDate, + formatPinTime, + formatPinDisplayDateTime, + clampPinScheduleDateTime, + extractTagSuggestions, + pinTopCardActions, + pinButtonStyle, + pinFieldRow, + savePinToBackend, + fetchSavedPins, + deletePinFromBackend, + publishPinNow, +} from './PinterestHelpers'; + +// ─── PinDateField sub-component ─────────────────────────────────────────────── + +function PinDateField({ + fieldId, + inputType, + labelText, + fieldValue, + minValue, + onFieldChange, + triedSaving, + validationMsg, +}) { + const hasError = triedSaving && !fieldValue; + return ( +
+ + + {hasError &&

{validationMsg}

} +
+ ); +} + +PinDateField.propTypes = { + fieldId: PropTypes.string.isRequired, + inputType: PropTypes.string.isRequired, + labelText: PropTypes.string.isRequired, + fieldValue: PropTypes.string.isRequired, + minValue: PropTypes.string.isRequired, + onFieldChange: PropTypes.func.isRequired, + triedSaving: PropTypes.bool.isRequired, + validationMsg: PropTypes.string.isRequired, +}; + +// ─── PinterestPinComposer ───────────────────────────────────────────────────── + +function PinterestPinComposer({ network }) { + const isDark = useSelector(state => state.theme.darkMode); + + // ── Form fields ──────────────────────────────────────────────────────────── + const [pinTitle, setPinTitle] = useState(''); + const [destinationLink, setDestinationLink] = useState(''); + const [boardName, setBoardName] = useState(''); + const [pinTag, setPinTag] = useState(''); + const [pinDescription, setPinDescription] = useState(''); + const [altCaption, setAltCaption] = useState(''); + const [imageUrl, setImageUrl] = useState(''); + + // ── UI state ─────────────────────────────────────────────────────────────── + const [activePane, setActivePane] = useState('compose'); + const [queuedDraftText, setQueuedDraftText] = useState(''); + const [queueDate, setQueueDate] = useState(() => formatPinDate(new Date())); + const [queueTime, setQueueTime] = useState(() => formatPinTime(new Date())); + const [pinQueue, setPinQueue] = useState([]); + const [editingQueueEntryId, setEditingQueueEntryId] = useState(null); + const [triedSavingQueue, setTriedSavingQueue] = useState(false); + const [tagSuggestions, setTagSuggestions] = useState([]); + const [isLoadingQueue, setIsLoadingQueue] = useState(false); + const [isSyncing, setIsSyncing] = useState(false); + + const paneTabs = useMemo( + () => [ + { id: 'compose', label: '📌 Create Pin' }, + { id: 'queue', label: '🗓️ Pin Queue' }, + ], + [], + ); + + // ── Derived validation ───────────────────────────────────────────────────── + + const cleanTitle = pinTitle.trim(); + const cleanLink = destinationLink.trim(); + const cleanBoard = boardName.trim(); + const cleanDesc = pinDescription.trim(); + const cleanTag = pinTag.trim(); + const cleanAlt = altCaption.trim(); + const cleanImage = imageUrl.trim(); + + const titleWithinRange = cleanTitle.length >= PIN_TITLE_MIN && cleanTitle.length <= PIN_TITLE_MAX; + const linkIsValid = cleanLink.length === 0 || /^https?:\/\//i.test(cleanLink); + const boardIsValid = cleanBoard.length >= 1 && cleanBoard.length <= 50; + const descIsValid = cleanDesc.length <= PIN_DESC_MAX; + const altIsValid = cleanAlt.length <= PIN_NOTE_MAX; + const imageIsValid = cleanImage.length === 0 || /^https?:\/\//i.test(cleanImage); + + const canCopyDraft = titleWithinRange && boardIsValid; + // Pin needs an image to be published to Pinterest API + const canPublish = canCopyDraft && cleanImage.length > 0 && imageIsValid; + + const warnTitle = cleanTitle.length > 0 && !titleWithinRange; + const warnLink = cleanLink.length > 0 && !linkIsValid; + const warnBoard = cleanBoard.length > 0 && !boardIsValid; + const warnDesc = cleanDesc.length > 0 && !descIsValid; + const warnAlt = cleanAlt.length > 0 && !altIsValid; + const warnImage = cleanImage.length > 0 && !imageIsValid; + + const hasComposedContent = Boolean( + cleanTitle || cleanLink || cleanBoard || cleanDesc || cleanTag, + ); + + const pinPreview = useMemo(() => { + if (!hasComposedContent) return ''; + return buildPinPreview({ + title: pinTitle, + link: destinationLink, + board: cleanBoard, + tag: pinTag, + description: pinDescription, + alt: altCaption, + }); + }, [ + pinTitle, + destinationLink, + cleanBoard, + pinTag, + pinDescription, + altCaption, + hasComposedContent, + ]); + + const queueHasDraft = queuedDraftText.trim().length > 0; + + const activeQueueEntry = useMemo( + () => pinQueue.find(entry => entry.id === editingQueueEntryId) || null, + [editingQueueEntryId, pinQueue], + ); + + // ── Backend sync ─────────────────────────────────────────────────────────── + + // Always re-fetches from backend to get real MongoDB _ids + const syncQueueFromBackend = useCallback(async () => { + setIsLoadingQueue(true); + try { + const remoteEntries = await fetchSavedPins(); + setPinQueue(remoteEntries); + } catch { + toast.error('Could not load saved pins from the server.'); + } finally { + setIsLoadingQueue(false); + } + }, []); + + // ── Clipboard helper ─────────────────────────────────────────────────────── + + const copyToClipboard = async (text, fieldLabel) => { + const value = text?.trim(); + if (!value) { + toast.warn(`Nothing to copy for ${fieldLabel}.`); + return; + } + try { + await navigator.clipboard.writeText(value); + toast.success(`${fieldLabel} copied to clipboard`); + } catch { + toast.error(`Could not copy ${fieldLabel.toLowerCase()}.`); + } + }; + + // ── Compose handlers ─────────────────────────────────────────────────────── + + const handleClearCompose = () => { + setPinTitle(''); + setDestinationLink(''); + setBoardName(''); + setPinTag(''); + setPinDescription(''); + setAltCaption(''); + setImageUrl(''); + setTagSuggestions([]); + }; + + const openPinterestCreate = () => { + window.open('https://www.pinterest.com/pin/creation/button/', '_blank', 'noopener,noreferrer'); + }; + + const getUnfilledRequiredFields = () => { + const missing = []; + if (!cleanTitle) missing.push('Title'); + if (!cleanBoard) missing.push('Board'); + return missing.length > 0 ? missing.join(', ') : null; + }; + + const handleMoveToQueue = () => { + if (!hasComposedContent) { + toast.error('Nothing to queue yet. Add details in Create Pin first.'); + return; + } + const missing = getUnfilledRequiredFields(); + if (missing) { + toast.error(`Add ${missing} before queuing.`); + return; + } + const now = new Date(); + setQueueDate(formatPinDate(now)); + setQueueTime(formatPinTime(now)); + setQueuedDraftText(pinPreview); + setTriedSavingQueue(false); + setActivePane('queue'); + toast.success('Draft moved to Pin Queue.'); + }; + + // Publish immediately via POST /api/pinterest/createPin + const handlePublishNow = async () => { + if (!canPublish) { + toast.error('Add a valid image URL, title, and board before publishing.'); + return; + } + setIsSyncing(true); + try { + await publishPinNow({ + pinTitle: cleanTitle, + pinDescription: cleanDesc, + imgType: 'URL', + imageUrl: cleanImage, + }); + toast.success('Pin published to Pinterest!'); + } catch (err) { + toast.error(err.message || 'Could not publish pin. Please try again.'); + } finally { + setIsSyncing(false); + } + }; + + // ── Queue date/time handlers ─────────────────────────────────────────────── + + const nowRef = new Date(); + const todayStr = formatPinDate(nowRef); + const currentTimeStr = formatPinTime(nowRef); + const queueTimeFloor = queueDate === todayStr ? currentTimeStr : '00:00'; + + const applyQueueDateTime = (nextDate, nextTime) => { + const { date, time } = clampPinScheduleDateTime(nextDate, nextTime); + setQueueDate(date); + setQueueTime(time); + setTriedSavingQueue(false); + }; + + const handleQueueDateChange = evt => { + if (!evt.target.value) return; + applyQueueDateTime(evt.target.value, queueTime); + }; + + const handleQueueTimeChange = evt => { + if (!evt.target.value) return; + applyQueueDateTime(queueDate, evt.target.value); + }; + + const handleReturnToCompose = () => { + setTriedSavingQueue(false); + setActivePane('compose'); + }; + + // ── Queue save ───────────────────────────────────────────────────────────── + // savePinToBackend calls POST /api/pinterest/schedule (no id returned). + // We always re-fetch after save to get real MongoDB _ids for delete to work. + + const handlePersistQueueEntry = async () => { + setTriedSavingQueue(true); + if (!queueHasDraft) { + toast.warn('Add content to the queue before saving.'); + return; + } + if (!queueDate || !queueTime) { + toast.error('Choose a date and time for the pin.'); + return; + } + + const queueRecord = { + pinTitle: cleanTitle, + pinDescription: cleanDesc, + destinationLink: cleanLink, + boardName: cleanBoard, + pinTag: cleanTag, + altCaption: cleanAlt, + imgType: 'URL', + imageUrl: cleanImage, + scheduledDate: queueDate, + scheduledTime: queueTime, + }; + + setIsSyncing(true); + try { + await savePinToBackend(queueRecord); + // Re-fetch to get real MongoDB _ids — local ids are not reliable for delete + await syncQueueFromBackend(); + toast.success('Pin entry saved.'); + setTriedSavingQueue(false); + setEditingQueueEntryId(null); + } catch { + toast.error('Could not save the pin to the server. Please try again.'); + } finally { + setIsSyncing(false); + } + }; + + // ── Queue edit ───────────────────────────────────────────────────────────── + // Note: backend has no PUT /schedule route, so editing creates a new entry + // and removes the old one. + + const handleLoadQueueEntry = entryId => { + const target = pinQueue.find(e => e.id === entryId); + if (!target) return; + const { date, time } = clampPinScheduleDateTime(target.scheduledDate, target.scheduledTime); + // Re-populate compose fields from the stored postData title + setPinTitle(target.pinTitle || ''); + setQueuedDraftText(target.draftText || ''); + setQueueDate(date); + setQueueTime(time); + setTriedSavingQueue(false); + setEditingQueueEntryId(target.id); + setActivePane('queue'); + toast.info('Pin entry loaded for editing.'); + }; + + // ── Queue delete ─────────────────────────────────────────────────────────── + // Uses the real MongoDB _id returned by fetchSavedPins normalisation. + + const handleRemoveQueueEntry = async entryId => { + setIsSyncing(true); + try { + await deletePinFromBackend(entryId); + setPinQueue(prev => prev.filter(e => e.id !== entryId)); + if (editingQueueEntryId === entryId) setEditingQueueEntryId(null); + toast.success('Pin entry removed.'); + } catch { + toast.error('Could not remove the pin. Please try again.'); + } finally { + setIsSyncing(false); + } + }; + + // ── Publish from queue ───────────────────────────────────────────────────── + // Publishes immediately then removes from schedule queue. + + const handlePublishQueueEntry = async entry => { + setIsSyncing(true); + const parsedPostData = JSON.parse(entry.draftText); + try { + await publishPinNow({ + pinTitle: parsedPostData.title, + pinDescription: parsedPostData.description || '', + imgType: parsedPostData.media_source?.source_type === 'image_base64' ? 'FILE' : 'URL', + imageUrl: parsedPostData.media_source?.url || '', + mediaItems: + parsedPostData.media_source?.source_type === 'image_base64' + ? parsedPostData.media_source + : { url: parsedPostData.media_source?.url }, + }); + await deletePinFromBackend(entry.id); + setPinQueue(prev => prev.filter(e => e.id !== entry.id)); + toast.success('Pin published and removed from queue.'); + } catch (err) { + toast.error(err.message || 'Could not publish pin. Please try again.'); + } finally { + setIsSyncing(false); + } + }; + + // ── Pane switch ──────────────────────────────────────────────────────────── + + const handlePaneSwitch = paneId => { + if (paneId === 'compose') { + setEditingQueueEntryId(null); + setQueuedDraftText(''); + setTriedSavingQueue(false); + } + if (paneId === 'queue') { + syncQueueFromBackend(); + } + setActivePane(paneId); + }; + + // ── Tag suggestions ──────────────────────────────────────────────────────── + + const handleSuggestTags = () => { + const suggestions = extractTagSuggestions(pinTitle, pinDescription, boardName); + setTagSuggestions(suggestions); + if (suggestions.length === 0) toast.info('No tag suggestions found.'); + }; + + const handleClearTag = () => { + setPinTag(''); + setTagSuggestions([]); + }; + + // ── Render ───────────────────────────────────────────────────────────────── + + return ( +
+ {/* Pane tabs */} +
+ {paneTabs.map(({ id, label }) => ( + + ))} +
+ + {activePane === 'compose' ? ( + <> + {/* Top card */} +
+

Pinterest Pin Composer

+

Build your Pinterest pin content, then schedule it or publish it directly.

+
+ +
+
+ +
+ {/* Board */} +
+
+ + + {cleanBoard.length}/50 + +
+ setBoardName(sanitizeBoardName(e.target.value))} + className={classNames(pStyles['pin-field__input'], { + [pStyles['pin-field__input--invalid']]: warnBoard, + })} + placeholder="e.g. Home Decor Ideas" + maxLength={50} + /> + {!cleanBoard && ( +

+ Enter the board name to post this pin to. +

+ )} + {warnBoard && ( +

+ Board name is required (up to 50 characters). +

+ )} +
+ +
+
+ + {/* Title */} +
+
+ + + {cleanTitle.length}/{PIN_TITLE_MAX} + +
+ setPinTitle(e.target.value)} + className={classNames(pStyles['pin-field__input'], { + [pStyles['pin-field__input--invalid']]: warnTitle, + })} + placeholder="e.g. 10 minimalist bedroom ideas for small spaces" + maxLength={PIN_TITLE_MAX} + /> + {!cleanTitle && ( +

+ Keep titles descriptive. Pinterest allows up to {PIN_TITLE_MAX} characters. +

+ )} + {warnTitle && ( +

+ Title must be at least {PIN_TITLE_MIN} characters. +

+ )} +
+ + +
+
+ + {/* Image URL — required by Pinterest API */} +
+
+ + required to publish +
+ setImageUrl(e.target.value)} + className={classNames(pStyles['pin-field__input'], { + [pStyles['pin-field__input--invalid']]: warnImage, + })} + placeholder="https://example.com/image.jpg" + /> + {!cleanImage && ( +

+ Pinterest requires a publicly accessible image URL to create a pin. +

+ )} + {warnImage && ( +

+ Enter a valid image URL starting with http:// or https://. +

+ )} +
+ +
+
+ + {/* Destination link */} +
+
+ + optional +
+ setDestinationLink(e.target.value)} + className={classNames(pStyles['pin-field__input'], { + [pStyles['pin-field__input--invalid']]: warnLink, + })} + placeholder="https://" + /> + {!cleanLink && ( +

+ Link pins to the source page. Must start with http:// or https://. +

+ )} + {warnLink && ( +

+ Enter a valid URL starting with http:// or https://. +

+ )} +
+ +
+
+ + {/* Tag / keyword */} +
+
+ + optional +
+ setPinTag(e.target.value)} + className={pStyles['pin-field__input']} + placeholder="e.g. minimalism, interior design" + /> + {!cleanTag && ( +

+ Tag suggestions are based on your title and description. +

+ )} +
+ + +
+ {tagSuggestions.length > 0 && ( +
+ {tagSuggestions.map(suggestion => ( + + ))} +
+ )} +
+ + {/* Description */} +
+
+ + + {cleanDesc.length}/{PIN_DESC_MAX} + +
+