40182-vm/frontend/src/pages/export-studio.tsx
2026-06-02 21:14:07 +00:00

1003 lines
37 KiB
TypeScript

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: `<h1>7 Portable Power Station Uses for Weekend Travelers</h1>
<p>Weekend travelers are using compact power stations to keep phones, cameras, and small appliances running without hunting for outlets.</p>
<p>Our top recommendation is this <a href="https://www.amazon.com/portable-power-station/dp/B0POWER123?tag=publisher-20">portable power station</a> because it balances capacity, size, and price.</p>
<h2>Pair it with solar</h2>
<p>For longer trips, add a folding <a href="https://www.amazon.com/folding-solar-panel/dp/B0SOLAR456?tag=publisher-20">solar charging panel</a> to extend runtime while camping or tailgating.</p>`,
},
{
id: 'home-office-upgrades',
title: 'Home Office Upgrades That Make Content Creators Faster',
status: 'Sample article',
source: 'Fallback sample',
readingTime: '4 min read',
html: `<h1>Home Office Upgrades That Make Content Creators Faster</h1>
<p>A focused creator desk can reduce editing friction and make every recording session feel easier.</p>
<p>Start with an adjustable <a href="https://shareasale.com/r.cfm?b=123456&u=999999&m=77777&urllink=https%3A%2F%2Fmerchant.example%2Fdesk-light">LED desk light</a> for consistent video quality.</p>
<p>Then upgrade voiceovers with a plug-and-play <a href="https://merchant.example/usb-mic?aff=publisher-demo">USB microphone</a> that works with most recording apps.</p>`,
},
];
const formatLabels: Record<ExportFormat, string> = {
wordpress: 'WordPress WXR/XML',
html: 'HTML',
markdown: 'Markdown',
};
const historyStorageKey = 'affiliate-export-studio-history';
const escapeXml = (value: string) =>
value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
const stripTags = (value: string) =>
value
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const escapeHtml = (value: string) =>
value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
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(
`<a href="${escapeHtml(url.replace(/^<|>$/g, ''))}">${escapeHtml(
label,
)}</a>`,
);
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 `<h2>${convertInlineMarkdownLinks(
trimmedBlock.replace(/^##\s+/, ''),
)}</h2>`;
}
if (trimmedBlock.startsWith('# ')) {
return `<h1>${convertInlineMarkdownLinks(
trimmedBlock.replace(/^#\s+/, ''),
)}</h1>`;
}
const lines = trimmedBlock.split('\n');
const isList = lines.every((line) => /^[-*]\s+/.test(line.trim()));
if (isList) {
return `<ul>${lines
.map(
(line) =>
`<li>${convertInlineMarkdownLinks(
line.trim().replace(/^[-*]\s+/, ''),
)}</li>`,
)
.join('')}</ul>`;
}
return `<p>${convertInlineMarkdownLinks(trimmedBlock).replace(
/\n/g,
'<br />',
)}</p>`;
})
.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 `<h1>${escapeHtml(title)}</h1>${
excerpt ? `<p>${escapeHtml(excerpt)}</p>` : ''
}`;
};
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 =
/<a\b[^>]*?\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>(.*?)<\/h1>/gis, '# $1\n\n')
.replace(/<h2>(.*?)<\/h2>/gis, '## $1\n\n')
.replace(/<li>(.*?)<\/li>/gis, '- $1\n')
.replace(/<\/ul>/gi, '\n')
.replace(/<ul>/gi, '')
.replace(/<p>(.*?)<\/p>/gis, '$1\n\n')
.replace(/<br\s*\/?>(\n)?/gi, '\n')
.replace(/<[^>]*>/g, '')
.replace(/\n{3,}/g, '\n\n')
.trim();
const buildWordPressXml = (
article: Article,
html: string,
) => `<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0" xmlns:excerpt="http://wordpress.org/export/1.2/excerpt/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:wp="http://wordpress.org/export/1.2/">
<channel>
<title>LinkLift Studio Export</title>
<wp:wxr_version>1.2</wp:wxr_version>
<item>
<title>${escapeXml(article.title)}</title>
<wp:post_type>post</wp:post_type>
<wp:status>draft</wp:status>
<content:encoded><![CDATA[${html}]]></content:encoded>
</item>
</channel>
</rss>`;
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<string>();
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<ExportFormat>('wordpress');
const [tagReplacements, setTagReplacements] = useState<
Record<string, string>
>({});
const [urlOverrides, setUrlOverrides] = useState<Record<string, string>>({});
const [history, setHistory] = useState<ExportRecord[]>([]);
const [selectedExportId, setSelectedExportId] = useState<string | null>(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<HTMLSelectElement>) => {
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 (
<>
<Head>
<title>{getPageTitle('Article Export Studio')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={mdiFileSwapOutline}
title='Article Export Studio'
main
>
<BaseButton
href='/articles/articles-list'
label='Manage articles'
color='whiteDark'
roundedFull
/>
</SectionTitleLineWithButton>
<div className='mb-6 overflow-hidden rounded-[2rem] bg-[#12332F] p-6 text-white shadow-xl shadow-emerald-950/10'>
<div className='grid gap-6 lg:grid-cols-[1.3fr_0.7fr] lg:items-end'>
<div>
<p className='text-sm font-black uppercase tracking-[0.24em] text-[#B7F8DA]'>
Buyer workflow
</p>
<h2 className='mt-3 text-4xl font-black tracking-tight'>
Detect affiliate tags, personalize once, and export a ready
article.
</h2>
<p className='mt-3 max-w-3xl text-white/75'>
Choose a licensed article and the system automatically finds
common affiliate parameters like Amazon
<code className='mx-1 rounded bg-white/10 px-1.5 py-0.5 text-[#B7F8DA]'>
tag
</code>
, ShareASale
<code className='mx-1 rounded bg-white/10 px-1.5 py-0.5 text-[#B7F8DA]'>
u
</code>
, and generic
<code className='mx-1 rounded bg-white/10 px-1.5 py-0.5 text-[#B7F8DA]'>
aff
</code>{' '}
IDs.
</p>
</div>
<div className='grid grid-cols-3 gap-3 text-center'>
{[
[articlesForStudio.length, 'articles'],
[detectedGroups.length, 'tags found'],
[personalizedLinkCount, 'links ready'],
].map(([value, label]) => (
<div key={label} className='rounded-3xl bg-white/10 p-4'>
<div className='text-3xl font-black text-[#B7F8DA]'>
{value}
</div>
<div className='text-xs font-bold uppercase tracking-wide text-white/60'>
{label}
</div>
</div>
))}
</div>
</div>
</div>
{(successMessage || errorMessage) && (
<div
className={`mb-6 rounded-2xl border px-5 py-4 text-sm font-semibold ${
successMessage
? 'border-emerald-200 bg-emerald-50 text-emerald-800'
: 'border-red-200 bg-red-50 text-red-800'
}`}
>
{successMessage || errorMessage}
</div>
)}
<div className='grid gap-6 xl:grid-cols-[0.95fr_1.05fr]'>
<CardBox className='border-0 shadow-sm'>
<div className='mb-6 flex items-center gap-3'>
<span className='grid h-12 w-12 place-items-center rounded-2xl bg-blue-50 text-blue-700'>
<BaseIcon path={mdiLinkVariant} size='24' />
</span>
<div>
<h3 className='text-xl font-black text-slate-900 dark:text-white'>
Detected affiliate tags
</h3>
<p className='text-sm text-slate-500'>
Enter the buyer tag once; matching article links update
automatically.
</p>
</div>
</div>
<div className='space-y-5'>
<label className='block'>
<span className='mb-2 block text-sm font-bold text-slate-700 dark:text-slate-200'>
Licensed article
</span>
<select
value={selectedArticleId}
onChange={handleArticleChange}
className='h-12 w-full rounded-2xl border border-slate-200 bg-white px-4 text-sm font-semibold text-slate-800 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:bg-dark-900 dark:text-white'
>
{articlesForStudio.map((article) => (
<option key={article.id} value={article.id}>
{article.title}
</option>
))}
</select>
{articlesLoading && (
<p className='mt-2 text-xs font-semibold text-blue-600'>
Loading saved articles from the database
</p>
)}
{articleLoadError && (
<p className='mt-2 text-xs font-semibold text-amber-700'>
{articleLoadError}
</p>
)}
{isUsingFallbackArticles && !articleLoadError && (
<p className='mt-2 text-xs font-semibold text-amber-700'>
No saved articles were returned yet, so sample articles are
shown for testing.
</p>
)}
</label>
<div className='grid grid-cols-2 gap-3'>
<div className='rounded-2xl bg-slate-50 p-4 dark:bg-dark-800'>
<p className='text-xs font-bold uppercase tracking-wide text-slate-500'>
Status
</p>
<p className='mt-1 font-black text-slate-900 dark:text-white'>
{selectedArticle.status}
</p>
</div>
<div className='rounded-2xl bg-slate-50 p-4 dark:bg-dark-800'>
<p className='text-xs font-bold uppercase tracking-wide text-slate-500'>
Source
</p>
<p className='mt-1 font-black text-slate-900 dark:text-white'>
{selectedArticle.source}
</p>
</div>
</div>
{detectedGroups.length > 0 ? (
<div className='space-y-4'>
<div className='rounded-3xl border border-emerald-100 bg-emerald-50 p-4 text-sm text-emerald-900'>
<strong>
{detectedGroups.length} affiliate tag group
{detectedGroups.length === 1 ? '' : 's'} detected.
</strong>{' '}
Values entered here replace the publisher tag while
preserving the product URL.
</div>
{detectedGroups.map((group) => (
<label
key={group.key}
className='block rounded-3xl border border-slate-100 bg-white p-4 shadow-sm dark:border-dark-700 dark:bg-dark-900'
>
<div className='mb-4 flex flex-wrap items-center gap-2'>
<span className='rounded-full bg-emerald-100 px-3 py-1 text-xs font-black uppercase tracking-wide text-emerald-800'>
{group.network}
</span>
<span className='rounded-full bg-slate-100 px-3 py-1 text-xs font-bold uppercase tracking-wide text-slate-600 dark:bg-dark-800 dark:text-slate-300'>
{group.linkCount} link
{group.linkCount === 1 ? '' : 's'}
</span>
</div>
<div className='mb-4 grid gap-3 md:grid-cols-3'>
<div>
<p className='text-xs font-bold uppercase tracking-wide text-slate-500'>
Domain
</p>
<p className='mt-1 break-all text-sm font-black text-slate-900 dark:text-white'>
{group.domain}
</p>
</div>
<div>
<p className='text-xs font-bold uppercase tracking-wide text-slate-500'>
Parameter
</p>
<code className='mt-1 block rounded-xl bg-slate-950 px-3 py-2 text-xs text-[#B7F8DA]'>
?{group.parameter}=
</code>
</div>
<div>
<p className='text-xs font-bold uppercase tracking-wide text-slate-500'>
Publisher tag
</p>
<code className='mt-1 block break-all rounded-xl bg-slate-950 px-3 py-2 text-xs text-[#B7F8DA]'>
{group.currentValue}
</code>
</div>
</div>
<span className='mb-2 block text-sm font-bold text-slate-700 dark:text-slate-200'>
Buyer {group.parameter} value
</span>
<input
value={tagReplacements[group.key] ?? ''}
onChange={(event) =>
handleTagReplacementChange(
group.key,
event.target.value,
)
}
placeholder={getTagPlaceholder(group)}
className='h-12 w-full rounded-2xl border border-slate-200 bg-white px-4 text-sm text-slate-800 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:bg-dark-800 dark:text-white'
/>
</label>
))}
</div>
) : (
<div className='rounded-3xl border border-dashed border-slate-200 p-6 text-center text-sm text-slate-500'>
No simple affiliate tag parameters were found in this article.
Use the full URL override section below for redirect links or
unusual networks.
</div>
)}
<details className='rounded-3xl border border-slate-100 bg-slate-50 p-4 dark:border-dark-700 dark:bg-dark-800'>
<summary className='cursor-pointer text-sm font-black text-slate-800 dark:text-white'>
Advanced fallback: replace a full affiliate URL
</summary>
<div className='mt-4 space-y-4'>
{uniqueDetectedLinks.length === 0 ? (
<p className='rounded-2xl border border-dashed border-slate-200 bg-white p-4 text-sm text-slate-500 dark:border-dark-700 dark:bg-dark-900'>
No links were found in this article content.
</p>
) : (
uniqueDetectedLinks.map((link, index) => (
<label
key={link.originalUrl}
className='block rounded-3xl border border-slate-100 bg-white p-4 shadow-sm dark:border-dark-700 dark:bg-dark-900'
>
<span className='text-xs font-black uppercase tracking-wide text-slate-500'>
Link {index + 1} {link.network} {' '}
{link.linkText || link.domain}
</span>
<code className='mt-2 block break-all rounded-2xl bg-slate-950 px-3 py-2 text-xs text-[#B7F8DA]'>
{link.originalUrl}
</code>
<span className='mb-2 mt-4 block text-sm font-bold text-slate-700 dark:text-slate-200'>
Replacement URL
</span>
<input
value={urlOverrides[link.originalUrl] ?? ''}
onChange={(event) =>
handleUrlOverrideChange(
link.originalUrl,
event.target.value,
)
}
placeholder='https://your-affiliate-link.example/product?tag=buyer-id'
className='h-12 w-full rounded-2xl border border-slate-200 bg-white px-4 text-sm text-slate-800 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:bg-dark-800 dark:text-white'
/>
</label>
))
)}
</div>
</details>
<div className='rounded-3xl border border-amber-100 bg-amber-50 p-4 text-sm text-amber-900'>
<strong>Validation:</strong> at least one buyer tag or full URL
override is required. Empty fields keep the original publisher
link.
</div>
</div>
</CardBox>
<CardBox className='border-0 shadow-sm'>
<div className='mb-6 flex flex-col justify-between gap-4 sm:flex-row sm:items-center'>
<div className='flex items-center gap-3'>
<span className='grid h-12 w-12 place-items-center rounded-2xl bg-emerald-50 text-emerald-700'>
<BaseIcon path={mdiFileExportOutline} size='24' />
</span>
<div>
<h3 className='text-xl font-black text-slate-900 dark:text-white'>
Preview & download
</h3>
<p className='text-sm text-slate-500'>
Export a draft-ready file for WordPress or manual
publishing.
</p>
</div>
</div>
<select
value={format}
onChange={(event) =>
setFormat(event.target.value as ExportFormat)
}
className='h-11 rounded-2xl border border-slate-200 bg-white px-4 text-sm font-bold text-slate-800 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:bg-dark-900 dark:text-white'
>
<option value='wordpress'>WordPress WXR/XML</option>
<option value='html'>HTML</option>
<option value='markdown'>Markdown</option>
</select>
</div>
<div className='mb-5 max-h-[31rem] overflow-auto rounded-3xl border border-slate-100 bg-slate-50 p-5 dark:border-dark-700 dark:bg-dark-800'>
<div
className='prose max-w-none prose-slate dark:prose-invert'
dangerouslySetInnerHTML={{ __html: customizedHtml }}
/>
</div>
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
<div className='flex items-center gap-2 text-sm font-semibold text-slate-600 dark:text-slate-300'>
<BaseIcon path={mdiCheckCircleOutline} size='18' />
{personalizedLinkCount}/{uniqueDetectedLinks.length} links
personalized
</div>
<BaseButton
label={`Download ${formatLabels[format]}`}
icon={mdiDownloadBoxOutline}
color='success'
roundedFull
onClick={handleExport}
/>
</div>
</CardBox>
</div>
<div className='mt-6 grid gap-6 xl:grid-cols-[0.8fr_1.2fr]'>
<CardBox className='border-0 shadow-sm'>
<div className='mb-5 flex items-center gap-3'>
<span className='grid h-11 w-11 place-items-center rounded-2xl bg-slate-100 text-slate-700'>
<BaseIcon path={mdiHistory} size='22' />
</span>
<div>
<h3 className='text-lg font-black text-slate-900 dark:text-white'>
Export history
</h3>
<p className='text-sm text-slate-500'>
Saved locally for this first iteration.
</p>
</div>
</div>
{history.length === 0 ? (
<div className='rounded-3xl border border-dashed border-slate-200 p-6 text-center text-sm text-slate-500'>
No exports yet. Add a buyer tag or replacement URL and download
a file to see confirmation history here.
</div>
) : (
<div className='space-y-3'>
{history.map((record) => (
<button
key={record.id}
type='button'
onClick={() => setSelectedExportId(record.id)}
className={`w-full rounded-3xl border p-4 text-left transition focus:outline-none focus:ring-2 focus:ring-blue-200 ${
selectedExport?.id === record.id
? 'border-blue-300 bg-blue-50 dark:bg-blue-950/30'
: 'border-slate-100 bg-white hover:border-slate-200 dark:border-dark-700 dark:bg-dark-900'
}`}
>
<p className='font-black text-slate-900 dark:text-white'>
{record.fileName}
</p>
<p className='mt-1 text-xs font-semibold uppercase tracking-wide text-slate-500'>
{formatLabels[record.format]} {record.createdAt}
</p>
</button>
))}
</div>
)}
</CardBox>
<CardBox className='border-0 shadow-sm'>
<div className='mb-5 flex items-center gap-3'>
<span className='grid h-11 w-11 place-items-center rounded-2xl bg-purple-50 text-purple-700'>
<BaseIcon path={mdiFileDocumentOutline} size='22' />
</span>
<div>
<h3 className='text-lg font-black text-slate-900 dark:text-white'>
Export detail
</h3>
<p className='text-sm text-slate-500'>
A lightweight confirmation record for the buyer.
</p>
</div>
</div>
{selectedExport ? (
<div className='rounded-3xl bg-slate-50 p-5 dark:bg-dark-800'>
<div className='grid gap-4 md:grid-cols-2'>
<div>
<p className='text-xs font-bold uppercase tracking-wide text-slate-500'>
Article
</p>
<p className='mt-1 font-black text-slate-900 dark:text-white'>
{selectedExport.articleTitle}
</p>
</div>
<div>
<p className='text-xs font-bold uppercase tracking-wide text-slate-500'>
Format
</p>
<p className='mt-1 font-black text-slate-900 dark:text-white'>
{formatLabels[selectedExport.format]}
</p>
</div>
<div>
<p className='text-xs font-bold uppercase tracking-wide text-slate-500'>
Replacements
</p>
<p className='mt-1 font-black text-slate-900 dark:text-white'>
{selectedExport.replacements}
</p>
</div>
<div>
<p className='text-xs font-bold uppercase tracking-wide text-slate-500'>
Created
</p>
<p className='mt-1 font-black text-slate-900 dark:text-white'>
{selectedExport.createdAt}
</p>
</div>
</div>
<div className='mt-5 rounded-2xl bg-white p-4 text-sm leading-6 text-slate-600 dark:bg-dark-900 dark:text-slate-300'>
{selectedExport.preview}...
</div>
</div>
) : (
<div className='rounded-3xl border border-dashed border-slate-200 p-6 text-center text-sm text-slate-500'>
Select an export from history, or create the first download to
generate a detail record.
</div>
)}
</CardBox>
</div>
</SectionMain>
</>
);
};
ExportStudio.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};
export default ExportStudio;