1003 lines
37 KiB
TypeScript
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, '&')
|
|
.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, '"')
|
|
.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(
|
|
`<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;
|