import React, { ReactElement, useEffect, useMemo, useRef, useState, } from 'react'; import Head from 'next/head'; import { mdiCheckCircleOutline, mdiDownloadBoxOutline, mdiFileDocumentOutline, mdiFileExportOutline, mdiFileSwapOutline, mdiHistory, mdiLinkVariant, } from '@mdi/js'; import BaseButton from '../components/BaseButton'; import BaseIcon from '../components/BaseIcon'; import CardBox from '../components/CardBox'; import SectionMain from '../components/SectionMain'; import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'; import LayoutAuthenticated from '../layouts/Authenticated'; import { getPageTitle } from '../config'; import { fetch as fetchArticles } from '../stores/articles/articlesSlice'; import { useAppDispatch, useAppSelector } from '../stores/hooks'; import { applyAffiliateReplacements, detectAffiliateLinks, type AffiliateReplacementGroup, type DetectedContentLink, } from '../helpers/affiliateLinks'; type SourceArticleRecord = { id?: string; title?: string | null; slug?: string | null; status?: string | null; excerpt?: string | null; content_html?: string | null; content_markdown?: string | null; source_url?: string | null; published_at?: string | null; createdAt?: string | null; updatedAt?: string | null; }; type Article = { id: string; title: string; status: string; source: string; readingTime: string; html: string; }; type ExportFormat = 'wordpress' | 'html' | 'markdown'; type ExportRecord = { id: string; articleTitle: string; format: ExportFormat; createdAt: string; replacements: number; fileName: string; preview: string; }; const sampleArticles: Article[] = [ { id: 'portable-power-stations', title: '7 Portable Power Station Uses for Weekend Travelers', status: 'Sample article', source: 'Fallback sample', readingTime: '5 min read', html: `

7 Portable Power Station Uses for Weekend Travelers

Weekend travelers are using compact power stations to keep phones, cameras, and small appliances running without hunting for outlets.

Our top recommendation is this portable power station because it balances capacity, size, and price.

Pair it with solar

For longer trips, add a folding solar charging panel to extend runtime while camping or tailgating.

`, }, { id: 'home-office-upgrades', title: 'Home Office Upgrades That Make Content Creators Faster', status: 'Sample article', source: 'Fallback sample', readingTime: '4 min read', html: `

Home Office Upgrades That Make Content Creators Faster

A focused creator desk can reduce editing friction and make every recording session feel easier.

Start with an adjustable LED desk light for consistent video quality.

Then upgrade voiceovers with a plug-and-play USB microphone that works with most recording apps.

`, }, ]; const formatLabels: Record = { wordpress: 'WordPress WXR/XML', html: 'HTML', markdown: 'Markdown', }; const historyStorageKey = 'affiliate-export-studio-history'; const escapeXml = (value: string) => value .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); const stripTags = (value: string) => value .replace(/<[^>]*>/g, ' ') .replace(/\s+/g, ' ') .trim(); const escapeHtml = (value: string) => value .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); const markdownLinkPattern = /\[([^\]]+)]\((<[^>]+>|[^)\s]+)(?:\s+["'][^"']*["'])?\)/g; const convertInlineMarkdownLinks = (value: string) => { const parts: string[] = []; let lastIndex = 0; value.replace(markdownLinkPattern, (match, label, url, index) => { parts.push(escapeHtml(value.slice(lastIndex, index))); parts.push( `${escapeHtml( label, )}`, ); lastIndex = index + match.length; return match; }); parts.push(escapeHtml(value.slice(lastIndex))); return parts.join(''); }; const buildHtmlFromMarkdown = (markdown: string) => markdown .split(/\n{2,}/) .map((block) => { const trimmedBlock = block.trim(); if (!trimmedBlock) return ''; if (trimmedBlock.startsWith('## ')) { return `

${convertInlineMarkdownLinks( trimmedBlock.replace(/^##\s+/, ''), )}

`; } if (trimmedBlock.startsWith('# ')) { return `

${convertInlineMarkdownLinks( trimmedBlock.replace(/^#\s+/, ''), )}

`; } const lines = trimmedBlock.split('\n'); const isList = lines.every((line) => /^[-*]\s+/.test(line.trim())); if (isList) { return ``; } return `

${convertInlineMarkdownLinks(trimmedBlock).replace( /\n/g, '
', )}

`; }) .filter(Boolean) .join('\n'); const getStringValue = (value?: string | null) => typeof value === 'string' ? value.trim() : ''; const getSourceLabel = (sourceUrl?: string | null) => { const trimmedSourceUrl = getStringValue(sourceUrl); if (!trimmedSourceUrl) return 'Saved article'; try { return new URL(trimmedSourceUrl).hostname; } catch (error) { console.error('Failed to parse article source URL', { sourceUrl: trimmedSourceUrl, error, }); return trimmedSourceUrl; } }; const formatStatusLabel = (status?: string | null) => { const trimmedStatus = getStringValue(status); if (!trimmedStatus) return 'Unspecified'; return trimmedStatus.replace(/_/g, ' '); }; const estimateReadingTime = (html: string) => { const wordCount = stripTags(html).split(/\s+/).filter(Boolean).length; const minutes = Math.max(1, Math.ceil(wordCount / 200)); return `${minutes} min read`; }; const getArticleHtml = (record: SourceArticleRecord) => { const html = getStringValue(record.content_html); if (html) return html; const markdown = getStringValue(record.content_markdown); if (markdown) return buildHtmlFromMarkdown(markdown); const title = getStringValue(record.title) || 'Untitled article'; const excerpt = getStringValue(record.excerpt); return `

${escapeHtml(title)}

${ excerpt ? `

${escapeHtml(excerpt)}

` : '' }`; }; const normalizeArticleRecord = ( record: SourceArticleRecord, index: number, ): Article | null => { const html = getArticleHtml(record); const title = getStringValue(record.title) || `Article ${index + 1}`; if (!stripTags(html) && !title) return null; return { id: getStringValue(record.id) || getStringValue(record.slug) || `article-${index}`, title, status: formatStatusLabel(record.status), source: getSourceLabel(record.source_url), readingTime: estimateReadingTime(html), html, }; }; const slugify = (value: string) => value .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, ''); const htmlAnchorPattern = /]*?\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))[^>]*>([\s\S]*?)<\/a>/gi; const buildMarkdown = (html: string) => html .replace( htmlAnchorPattern, (_match, doubleQuoted, singleQuoted, bareUrl, label) => { const href = doubleQuoted || singleQuoted || bareUrl || ''; return `[${stripTags(label)}](${href})`; }, ) .replace(/

(.*?)<\/h1>/gis, '# $1\n\n') .replace(/

(.*?)<\/h2>/gis, '## $1\n\n') .replace(/
  • (.*?)<\/li>/gis, '- $1\n') .replace(/<\/ul>/gi, '\n') .replace(/
      /gi, '') .replace(/

      (.*?)<\/p>/gis, '$1\n\n') .replace(/(\n)?/gi, '\n') .replace(/<[^>]*>/g, '') .replace(/\n{3,}/g, '\n\n') .trim(); const buildWordPressXml = ( article: Article, html: string, ) => ` LinkLift Studio Export 1.2 ${escapeXml(article.title)} post draft `; const getExtension = (format: ExportFormat) => { if (format === 'wordpress') return 'xml'; if (format === 'markdown') return 'md'; return 'html'; }; const createDownload = ( fileName: string, content: string, format: ExportFormat, ) => { const mimeType = format === 'wordpress' ? 'application/xml' : 'text/plain'; const blob = new Blob([content], { type: `${mimeType};charset=utf-8` }); const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = fileName; document.body.appendChild(anchor); anchor.click(); anchor.remove(); URL.revokeObjectURL(url); }; const getUniqueLinksByUrl = (links: DetectedContentLink[]) => { const seenUrls = new Set(); return links.filter((link) => { if (seenUrls.has(link.originalUrl)) { return false; } seenUrls.add(link.originalUrl); return true; }); }; const getTagPlaceholder = (group: AffiliateReplacementGroup) => { if (group.parameter.toLowerCase() === 'tag') { return 'yourstore-20'; } if (group.parameter.toLowerCase().includes('sub')) { return 'buyer-sub-id'; } return 'buyer-affiliate-id'; }; const ExportStudio = () => { const dispatch = useAppDispatch(); const hasRequestedArticles = useRef(false); const { articles, loading: articlesLoading } = useAppSelector( (state) => state.articles, ); const { currentUser } = useAppSelector((state) => state.auth); const [selectedArticleId, setSelectedArticleId] = useState(''); const [hasAttemptedArticleLoad, setHasAttemptedArticleLoad] = useState(false); const [articleLoadError, setArticleLoadError] = useState(''); const [format, setFormat] = useState('wordpress'); const [tagReplacements, setTagReplacements] = useState< Record >({}); const [urlOverrides, setUrlOverrides] = useState>({}); const [history, setHistory] = useState([]); const [selectedExportId, setSelectedExportId] = useState(null); const [successMessage, setSuccessMessage] = useState(''); const [errorMessage, setErrorMessage] = useState(''); const articleRows = Array.isArray(articles) ? articles : []; const apiArticles = useMemo( () => articleRows .map((record, index) => normalizeArticleRecord(record as SourceArticleRecord, index), ) .filter((article): article is Article => Boolean(article)), [articleRows], ); const articlesForStudio = apiArticles.length > 0 ? apiArticles : sampleArticles; const isUsingFallbackArticles = hasAttemptedArticleLoad && apiArticles.length === 0; const selectedArticle = useMemo( () => articlesForStudio.find((article) => article.id === selectedArticleId) ?? articlesForStudio[0] ?? sampleArticles[0], [articlesForStudio, selectedArticleId], ); const affiliateDetection = useMemo( () => detectAffiliateLinks(selectedArticle.html), [selectedArticle.html], ); const detectedGroups = affiliateDetection.groups; const detectedLinks = affiliateDetection.links; const uniqueDetectedLinks = useMemo( () => getUniqueLinksByUrl(detectedLinks), [detectedLinks], ); const customizedHtml = useMemo( () => applyAffiliateReplacements( selectedArticle.html, detectedLinks, tagReplacements, urlOverrides, ), [detectedLinks, selectedArticle.html, tagReplacements, urlOverrides], ); const completedTagGroups = detectedGroups.filter((group) => tagReplacements[group.key]?.trim(), ).length; const completedUrlOverrides = uniqueDetectedLinks.filter((link) => urlOverrides[link.originalUrl]?.trim(), ).length; const hasAnyReplacement = completedTagGroups + completedUrlOverrides > 0; const personalizedLinkCount = uniqueDetectedLinks.filter( (link) => Boolean(urlOverrides[link.originalUrl]?.trim()) || link.parameters.some((parameter) => tagReplacements[parameter.replacementKey]?.trim(), ), ).length; const selectedExport = history.find((record) => record.id === selectedExportId) ?? history[0]; useEffect(() => { if (!currentUser || hasRequestedArticles.current) return; hasRequestedArticles.current = true; setArticleLoadError(''); dispatch( fetchArticles({ query: '/export-studio/source-articles?page=0&limit=50', }), ) .unwrap() .catch((error) => { console.error('Failed to load export studio articles', error); setArticleLoadError( 'Could not load saved articles. The sample articles below are available as a fallback.', ); }) .finally(() => { setHasAttemptedArticleLoad(true); }); }, [currentUser, dispatch]); useEffect(() => { if (articlesForStudio.length === 0) return; if ( !articlesForStudio.some((article) => article.id === selectedArticleId) ) { setSelectedArticleId(articlesForStudio[0].id); } }, [articlesForStudio, selectedArticleId]); useEffect(() => { setTagReplacements({}); setUrlOverrides({}); setSuccessMessage(''); setErrorMessage(''); }, [selectedArticleId]); useEffect(() => { if (typeof window === 'undefined') return; const savedHistory = window.localStorage.getItem(historyStorageKey); if (savedHistory) { try { const parsedHistory = JSON.parse(savedHistory) as ExportRecord[]; setHistory(parsedHistory); setSelectedExportId(parsedHistory[0]?.id ?? null); } catch (error) { console.error('Failed to parse export history', error); } } }, []); useEffect(() => { if (typeof window === 'undefined') return; window.localStorage.setItem(historyStorageKey, JSON.stringify(history)); }, [history]); const handleArticleChange = (event: React.ChangeEvent) => { setSelectedArticleId(event.target.value); setTagReplacements({}); setUrlOverrides({}); setSuccessMessage(''); setErrorMessage(''); }; const handleTagReplacementChange = (key: string, value: string) => { setTagReplacements((current) => ({ ...current, [key]: value })); setSuccessMessage(''); setErrorMessage(''); }; const handleUrlOverrideChange = (url: string, value: string) => { setUrlOverrides((current) => ({ ...current, [url]: value })); setSuccessMessage(''); setErrorMessage(''); }; const buildExportContent = () => { if (format === 'wordpress') return buildWordPressXml(selectedArticle, customizedHtml); if (format === 'markdown') return buildMarkdown(customizedHtml); return customizedHtml; }; const handleExport = () => { if (!hasAnyReplacement) { setErrorMessage( 'Add at least one buyer affiliate tag or full URL override before exporting.', ); return; } const fileName = `${slugify(selectedArticle.title)}-${Date.now()}.${getExtension(format)}`; const content = buildExportContent(); createDownload(fileName, content, format); const record: ExportRecord = { id: `${Date.now()}`, articleTitle: selectedArticle.title, format, createdAt: new Date().toLocaleString(), replacements: personalizedLinkCount, fileName, preview: stripTags(customizedHtml).slice(0, 170), }; setHistory((current) => [record, ...current].slice(0, 8)); setSelectedExportId(record.id); setSuccessMessage(`${formatLabels[format]} export created and downloaded.`); setErrorMessage(''); }; return ( <> {getPageTitle('Article Export Studio')}

      Buyer workflow

      Detect affiliate tags, personalize once, and export a ready article.

      Choose a licensed article and the system automatically finds common affiliate parameters like Amazon tag , ShareASale u , and generic aff {' '} IDs.

      {[ [articlesForStudio.length, 'articles'], [detectedGroups.length, 'tags found'], [personalizedLinkCount, 'links ready'], ].map(([value, label]) => (
      {value}
      {label}
      ))}
      {(successMessage || errorMessage) && (
      {successMessage || errorMessage}
      )}

      Detected affiliate tags

      Enter the buyer tag once; matching article links update automatically.

      Status

      {selectedArticle.status}

      Source

      {selectedArticle.source}

      {detectedGroups.length > 0 ? (
      {detectedGroups.length} affiliate tag group {detectedGroups.length === 1 ? '' : 's'} detected. {' '} Values entered here replace the publisher tag while preserving the product URL.
      {detectedGroups.map((group) => ( ))}
      ) : (
      No simple affiliate tag parameters were found in this article. Use the full URL override section below for redirect links or unusual networks.
      )}
      Advanced fallback: replace a full affiliate URL
      {uniqueDetectedLinks.length === 0 ? (

      No links were found in this article content.

      ) : ( uniqueDetectedLinks.map((link, index) => ( )) )}
      Validation: at least one buyer tag or full URL override is required. Empty fields keep the original publisher link.

      Preview & download

      Export a draft-ready file for WordPress or manual publishing.

      {personalizedLinkCount}/{uniqueDetectedLinks.length} links personalized

      Export history

      Saved locally for this first iteration.

      {history.length === 0 ? (
      No exports yet. Add a buyer tag or replacement URL and download a file to see confirmation history here.
      ) : (
      {history.map((record) => ( ))}
      )}

      Export detail

      A lightweight confirmation record for the buyer.

      {selectedExport ? (

      Article

      {selectedExport.articleTitle}

      Format

      {formatLabels[selectedExport.format]}

      Replacements

      {selectedExport.replacements}

      Created

      {selectedExport.createdAt}

      {selectedExport.preview}...
      ) : (
      Select an export from history, or create the first download to generate a detail record.
      )}
      ); }; ExportStudio.getLayout = function getLayout(page: ReactElement) { return {page}; }; export default ExportStudio;