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..3800231154 --- /dev/null +++ b/src/components/Announcements/platforms/pinterest/PinterestHelpers.js @@ -0,0 +1,309 @@ +// ─── 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()}_${crypto.randomUUID()}`; +} + +// ─── 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..4477ec92f5 --- /dev/null +++ b/src/components/Announcements/platforms/pinterest/index.jsx @@ -0,0 +1,1148 @@ +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 ───────────────────────────────────────────────────────────── + +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, +}; + +// ─── PinComposePane ─────────────────────────────────────────────────────────── + +function PinComposePane({ composeProps }) { + const { + isDark, + pinTitle, + setPinTitle, + destinationLink, + setDestinationLink, + boardName, + setBoardName, + pinTag, + setPinTag, + pinDescription, + setPinDescription, + altCaption, + setAltCaption, + imageUrl, + setImageUrl, + tagSuggestions, + cleanTitle, + cleanLink, + cleanBoard, + cleanDesc, + cleanTag, + cleanAlt, + cleanImage, + warnTitle, + warnLink, + warnBoard, + warnDesc, + warnAlt, + warnImage, + canCopyDraft, + canPublish, + isSyncing, + pinPreview, + handleClearCompose, + handleMoveToQueue, + handlePublishNow, + handleSuggestTags, + handleClearTag, + copyToClipboard, + openPinterestCreate, + } = composeProps; + + return ( + <> +
+

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 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 */} +
+
+ + 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} + +
+