From f0df90f9b7678cbab875b02dd029449b909d62d0 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Tue, 2 Jun 2026 21:14:07 +0000 Subject: [PATCH] Autosave: 20260602-211412 --- backend/src/db/api/articles.js | 40 +- .../db/seeders/20231127130745-sample-data.js | 16 +- backend/src/routes/articles.js | 10 + frontend/src/components/NavBarItem.tsx | 3 +- frontend/src/helpers/affiliateLinks.ts | 389 +++++++ frontend/src/layouts/Authenticated.tsx | 3 +- frontend/src/menuAside.ts | 6 + frontend/src/pages/export-studio.tsx | 1002 +++++++++++++++++ frontend/src/pages/index.tsx | 285 +++-- 9 files changed, 1590 insertions(+), 164 deletions(-) create mode 100644 frontend/src/helpers/affiliateLinks.ts create mode 100644 frontend/src/pages/export-studio.tsx diff --git a/backend/src/db/api/articles.js b/backend/src/db/api/articles.js index 53c72ed..80baa95 100644 --- a/backend/src/db/api/articles.js +++ b/backend/src/db/api/articles.js @@ -1,7 +1,6 @@ const db = require('../models'); const FileDBApi = require('./file'); -const crypto = require('crypto'); const Utils = require('../utils'); @@ -382,10 +381,6 @@ module.exports = class ArticlesDBApi { offset = currentPage * limit; - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; - let include = [ @@ -665,6 +660,41 @@ module.exports = class ArticlesDBApi { } } + static async findForExportStudio(filter = {}, options = {}) { + const transaction = (options && options.transaction) || undefined; + const limit = Math.min(Number(filter.limit) || 50, 100); + const currentPage = Number(filter.page) || 0; + const offset = currentPage * limit; + const where = {}; + + if (filter.status) { + where.status = filter.status; + } + + const { rows, count } = await db.articles.findAndCountAll({ + attributes: [ + 'id', + 'title', + 'slug', + 'status', + 'excerpt', + 'content_html', + 'content_markdown', + 'source_url', + 'published_at', + 'createdAt', + 'updatedAt', + ], + where, + order: [['updatedAt', 'desc']], + limit, + offset, + transaction, + }); + + return { rows, count }; + } + static async findAllAutocomplete(query, limit, offset, ) { let where = {}; diff --git a/backend/src/db/seeders/20231127130745-sample-data.js b/backend/src/db/seeders/20231127130745-sample-data.js index 2a36ca2..6b1c273 100644 --- a/backend/src/db/seeders/20231127130745-sample-data.js +++ b/backend/src/db/seeders/20231127130745-sample-data.js @@ -100,10 +100,7 @@ const ArticlesData = [ - "content_markdown": "# Best Budget Laptops for Remote Work -Working from home is easier with the right laptop. - -Top pick: [Budget Laptop A](https://example.com/affiliate/laptop-1?tag=ORIGINALTAG)", + "content_markdown": "# Best Budget Laptops for Remote Work\nWorking from home is easier with the right laptop.\n\nTop pick: [Budget Laptop A](https://example.com/affiliate/laptop-1?tag=ORIGINALTAG)", @@ -191,9 +188,7 @@ Top pick: [Budget Laptop A](https://example.com/affiliate/laptop-1?tag=ORIGINALT - "content_markdown": "# Home Office Essentials Checklist -- [Standing Desk](https://example.com/affiliate/standing-desk?tag=ORIGINALTAG) -- [Ergonomic Chair](https://example.com/affiliate/ergonomic-chair?tag=ORIGINALTAG)", + "content_markdown": "# Home Office Essentials Checklist\n- [Standing Desk](https://example.com/affiliate/standing-desk?tag=ORIGINALTAG)\n- [Ergonomic Chair](https://example.com/affiliate/ergonomic-chair?tag=ORIGINALTAG)", @@ -281,8 +276,7 @@ Top pick: [Budget Laptop A](https://example.com/affiliate/laptop-1?tag=ORIGINALT - "content_markdown": "# Beginner Guide to Email Marketing Tools -Starter option: [Email Platform X](https://example.com/affiliate/email-platform?ref=ORIGINALTAG)", + "content_markdown": "# Beginner Guide to Email Marketing Tools\nStarter option: [Email Platform X](https://example.com/affiliate/email-platform?ref=ORIGINALTAG)", @@ -3345,7 +3339,7 @@ const ExportsData = [ module.exports = { - up: async (queryInterface, Sequelize) => { + up: async () => { @@ -3742,7 +3736,7 @@ module.exports = { }, - down: async (queryInterface, Sequelize) => { + down: async (queryInterface) => { diff --git a/backend/src/routes/articles.js b/backend/src/routes/articles.js index 382ae97..27a2894 100644 --- a/backend/src/routes/articles.js +++ b/backend/src/routes/articles.js @@ -324,6 +324,16 @@ router.get('/', wrapAsync(async (req, res) => { })); +router.get('/export-studio/source-articles', wrapAsync(async (req, res) => { + const currentUser = req.currentUser; + const payload = await ArticlesDBApi.findForExportStudio( + req.query, + { currentUser }, + ); + + res.status(200).send(payload); +})); + /** * @swagger * /api/articles/count: diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index 72935e6..fcbd9b9 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,5 @@ -import React, {useEffect, useRef} from 'react' +import React, { useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { useState } from 'react' import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' diff --git a/frontend/src/helpers/affiliateLinks.ts b/frontend/src/helpers/affiliateLinks.ts new file mode 100644 index 0000000..217188d --- /dev/null +++ b/frontend/src/helpers/affiliateLinks.ts @@ -0,0 +1,389 @@ +export type LinkSource = 'html' | 'markdown' | 'raw'; + +export type AffiliateParameterMatch = { + parameter: string; + value: string; + replacementKey: string; +}; + +export type DetectedContentLink = { + id: string; + originalUrl: string; + parsedUrl: string; + linkText: string; + domain: string; + network: string; + source: LinkSource; + parameters: AffiliateParameterMatch[]; + isAffiliateCandidate: boolean; +}; + +export type AffiliateReplacementGroup = { + key: string; + domain: string; + network: string; + parameter: string; + currentValue: string; + linkCount: number; + sampleUrl: string; +}; + +export type AffiliateDetectionResult = { + links: DetectedContentLink[]; + affiliateLinks: DetectedContentLink[]; + groups: AffiliateReplacementGroup[]; +}; + +type ExtractedLink = { + originalUrl: string; + linkText: string; + source: LinkSource; +}; + +type AffiliateNetworkRule = { + label: string; + domains: string[]; + parameters: string[]; +}; + +const genericAffiliateParameters = new Set([ + 'tag', + 'ascsubtag', + 'aff', + 'affid', + 'affiliate', + 'affiliateid', + 'affiliate_id', + 'ref', + 'refid', + 'ref_id', + 'partner', + 'partnerid', + 'partner_id', + 'campid', + 'campaign', + 'sid', + 'subid', + 'sub_id', + 'sub1', + 'sub2', + 'sub3', + 'clickid', + 'irclickid', +]); + +const affiliateNetworkRules: AffiliateNetworkRule[] = [ + { + label: 'Amazon Associates', + domains: ['amazon.', 'amzn.to'], + parameters: ['tag', 'ascsubtag'], + }, + { + label: 'ShareASale', + domains: ['shareasale.com'], + parameters: ['u', 'afftrack'], + }, + { + label: 'Awin', + domains: ['awin1.com', 'awstrack.me'], + parameters: ['awinaffid', 'clickref', 'p'], + }, + { + label: 'Rakuten Advertising', + domains: ['click.linksynergy.com', 'linksynergy.com'], + parameters: ['id', 'u1'], + }, + { + label: 'CJ Affiliate', + domains: [ + 'anrdoezrs.net', + 'dpbolvw.net', + 'jdoqocy.com', + 'kqzyfj.com', + 'tkqlhce.com', + ], + parameters: ['sid', 'cjevent'], + }, + { + label: 'Impact', + domains: ['impact.com', 'impactradius.com'], + parameters: ['subid1', 'subid2', 'subid3', 'irclickid'], + }, + { + label: 'Example affiliate merchant', + domains: ['merchant.example'], + parameters: ['aff'], + }, +]; + +const stripTags = (value: string) => + value + .replace(/<[^>]*>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + +const decodeBasicHtmlEntities = (value: string) => + value + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>'); + +const makeReplacementKey = ( + domain: string, + parameter: string, + currentValue: string, +) => + [domain, parameter, currentValue] + .map((part) => encodeURIComponent(part)) + .join('|'); + +const getUrlForParsing = (url: string) => { + const decodedUrl = decodeBasicHtmlEntities(url.trim()); + + if (decodedUrl.startsWith('//')) { + return `https:${decodedUrl}`; + } + + return decodedUrl; +}; + +const getParsedUrl = (url: string) => { + const parseableUrl = getUrlForParsing(url); + + if (!/^https?:\/\//i.test(parseableUrl)) { + return null; + } + + try { + return new URL(parseableUrl); + } catch (error) { + console.error('Failed to parse article link URL', { url, error }); + return null; + } +}; + +const getNetworkRule = (domain: string) => { + const normalizedDomain = domain.toLowerCase(); + + return affiliateNetworkRules.find((rule) => + rule.domains.some((knownDomain) => normalizedDomain.includes(knownDomain)), + ); +}; + +const isAffiliateParameter = ( + parameter: string, + networkRule?: AffiliateNetworkRule, +) => { + const normalizedParameter = parameter.toLowerCase(); + + return ( + genericAffiliateParameters.has(normalizedParameter) || + Boolean( + networkRule?.parameters.some( + (networkParameter) => + networkParameter.toLowerCase() === normalizedParameter, + ), + ) + ); +}; + +const dedupeExtractedLinks = (links: ExtractedLink[]) => { + const seen = new Set(); + + return links.filter((link) => { + const key = getUrlForParsing(link.originalUrl); + + if (seen.has(key)) { + return false; + } + + seen.add(key); + return true; + }); +}; + +const extractLinks = (content: string) => { + const links: ExtractedLink[] = []; + const htmlLinkPattern = + /]*?\bhref\s*=\s*(["'])(.*?)\1[^>]*>([\s\S]*?)<\/a>/gi; + const markdownLinkPattern = + /(!)?\[([^\]]+)]\((<[^>]+>|[^)\s]+)(?:\s+["'][^"']*["'])?\)/g; + const rawUrlPattern = /https?:\/\/[^\s"'<>)]*/g; + + for (const match of content.matchAll(htmlLinkPattern)) { + links.push({ + originalUrl: match[2], + linkText: stripTags(match[3]), + source: 'html', + }); + } + + for (const match of content.matchAll(markdownLinkPattern)) { + if (match[1]) { + continue; + } + + links.push({ + originalUrl: match[3].replace(/^<|>$/g, ''), + linkText: match[2], + source: 'markdown', + }); + } + + for (const match of content.matchAll(rawUrlPattern)) { + links.push({ + originalUrl: match[0], + linkText: 'Raw URL', + source: 'raw', + }); + } + + return dedupeExtractedLinks(links); +}; + +const formatUrlForOriginalSource = ( + link: DetectedContentLink, + updatedUrl: string, +) => { + if (link.source === 'html' && link.originalUrl.includes('&')) { + return updatedUrl.replace(/&/g, '&'); + } + + return updatedUrl; +}; + +const replaceEvery = ( + content: string, + searchValue: string, + replacementValue: string, +) => content.split(searchValue).join(replacementValue); + +export const detectAffiliateLinks = ( + content: string, +): AffiliateDetectionResult => { + const extractedLinks = extractLinks(content); + const groupMap = new Map(); + + const links = extractedLinks + .map((link, index): DetectedContentLink | null => { + const parsedUrl = getParsedUrl(link.originalUrl); + + if (!parsedUrl) { + return null; + } + + const domain = parsedUrl.hostname.replace(/^www\./, ''); + const networkRule = getNetworkRule(domain); + const parameters: AffiliateParameterMatch[] = []; + + parsedUrl.searchParams.forEach((value, parameter) => { + if (!isAffiliateParameter(parameter, networkRule)) { + return; + } + + parameters.push({ + parameter, + value, + replacementKey: makeReplacementKey(domain, parameter, value), + }); + }); + + const detectedLink: DetectedContentLink = { + id: `${index}-${domain}`, + originalUrl: link.originalUrl, + parsedUrl: getUrlForParsing(link.originalUrl), + linkText: link.linkText, + domain, + network: networkRule?.label ?? 'Detected link', + source: link.source, + parameters, + isAffiliateCandidate: parameters.length > 0 || Boolean(networkRule), + }; + + parameters.forEach((match) => { + const existingGroup = groupMap.get(match.replacementKey); + + if (existingGroup) { + existingGroup.linkCount += 1; + return; + } + + groupMap.set(match.replacementKey, { + key: match.replacementKey, + domain, + network: detectedLink.network, + parameter: match.parameter, + currentValue: match.value, + linkCount: 1, + sampleUrl: link.originalUrl, + }); + }); + + return detectedLink; + }) + .filter((link): link is DetectedContentLink => Boolean(link)); + + return { + links, + affiliateLinks: links.filter((link) => link.isAffiliateCandidate), + groups: Array.from(groupMap.values()), + }; +}; + +export const applyAffiliateReplacements = ( + content: string, + links: DetectedContentLink[], + tagReplacements: Record, + urlOverrides: Record, +) => { + let customizedContent = content; + + links.forEach((link) => { + const urlOverride = urlOverrides[link.originalUrl]?.trim(); + + if (urlOverride) { + customizedContent = replaceEvery( + customizedContent, + link.originalUrl, + urlOverride, + ); + return; + } + + if (link.parameters.length === 0) { + return; + } + + const parsedUrl = getParsedUrl(link.originalUrl); + + if (!parsedUrl) { + return; + } + + let hasReplacement = false; + + link.parameters.forEach((match) => { + const replacementValue = tagReplacements[match.replacementKey]?.trim(); + + if (!replacementValue) { + return; + } + + parsedUrl.searchParams.set(match.parameter, replacementValue); + hasReplacement = true; + }); + + if (hasReplacement) { + customizedContent = replaceEvery( + customizedContent, + link.originalUrl, + formatUrlForOriginalSource(link, parsedUrl.toString()), + ); + } + }); + + return customizedContent; +}; diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..73d8391 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,5 +1,4 @@ -import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' +import React, { ReactNode, useEffect, useState } from 'react' import jwt from 'jsonwebtoken'; import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import menuAside from '../menuAside' diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 5564ee3..6a39487 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -8,6 +8,12 @@ const menuAside: MenuAsideItem[] = [ label: 'Dashboard', }, + { + href: '/export-studio', + icon: icon.mdiFileSwapOutline, + label: 'Export Studio', + }, + { href: '/users/users-list', label: 'Users', diff --git a/frontend/src/pages/export-studio.tsx b/frontend/src/pages/export-studio.tsx new file mode 100644 index 0000000..a0dc2b5 --- /dev/null +++ b/frontend/src/pages/export-studio.tsx @@ -0,0 +1,1002 @@ +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 `
    ${lines + .map( + (line) => + `
  • ${convertInlineMarkdownLinks( + line.trim().replace(/^[-*]\s+/, ''), + )}
  • `, + ) + .join('')}
`; + } + + 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; diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index 6af1721..0e69aa9 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -1,161 +1,159 @@ - -import React, { useEffect, useState } from 'react'; +import React from 'react'; import type { ReactElement } from 'react'; import Head from 'next/head'; import Link from 'next/link'; +import { mdiArrowRight, mdiCheckCircleOutline, mdiDownloadBoxOutline, mdiFileSwapOutline, mdiLinkVariant } from '@mdi/js'; import BaseButton from '../components/BaseButton'; -import CardBox from '../components/CardBox'; -import SectionFullScreen from '../components/SectionFullScreen'; +import BaseIcon from '../components/BaseIcon'; import LayoutGuest from '../layouts/Guest'; -import BaseDivider from '../components/BaseDivider'; -import BaseButtons from '../components/BaseButtons'; import { getPageTitle } from '../config'; -import { useAppSelector } from '../stores/hooks'; -import CardBoxComponentTitle from "../components/CardBoxComponentTitle"; -import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'; +const workflowSteps = [ + 'Preload WordPress-ready articles with your original affiliate links.', + 'Sell access to bundles through your funnel and send buyers into the portal.', + 'Buyers paste their own affiliate URLs and export a publish-ready file.', +]; + +const exportOptions = ['WordPress WXR/XML', 'Clean HTML', 'Markdown']; export default function Starter() { - const [illustrationImage, setIllustrationImage] = useState({ - src: undefined, - photographer: undefined, - photographer_url: undefined, - }) - const [illustrationVideo, setIllustrationVideo] = useState({video_files: []}) - const [contentType, setContentType] = useState('video'); - const [contentPosition, setContentPosition] = useState('left'); - const textColor = useAppSelector((state) => state.style.linkColor); - - const title = 'Affiliate Article Licensing' - - // Fetch Pexels image/video - useEffect(() => { - async function fetchData() { - const image = await getPexelsImage(); - const video = await getPexelsVideo(); - setIllustrationImage(image); - setIllustrationVideo(video); - } - fetchData(); - }, []); - - const imageBlock = (image) => ( - - ); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
      - - -
      ) - } - }; - return ( -
      +
      - {getPageTitle('Starter Page')} + {getPageTitle('Affiliate Article Licensing')} + - -
      - {contentType === 'image' && contentPosition !== 'background' - ? imageBlock(illustrationImage) - : null} - {contentType === 'video' && contentPosition !== 'background' - ? videoBlock(illustrationVideo) - : null} -
      - - - -
      -

      This is a React.js/Node.js app generated by the Flatlogic Web App Generator

      -

      For guides and documentation please check - your local README.md and the Flatlogic documentation

      -
      - - - - - -
      +
      +
      + + + + + LinkLift Studio + +
      -
      - -
      -

      © 2026 {title}. All rights reserved

      - - Privacy Policy - -
      + +
      +
      +
      +
      +
      +
      + + Affiliate content bundles for marketers +
      +

      + Sell article bundles that buyers can personalize in minutes. +

      +

      + Upload WordPress-ready articles once. Buyers log in, replace your affiliate links with theirs, and download a file they can import or publish fast. +

      +
      + + +
      +
      + {[ + ['3', 'export formats'], + ['2 min', 'buyer setup'], + ['0', 'plugin required'], + ].map(([value, label]) => ( +
      +
      {value}
      +
      {label}
      +
      + ))} +
      +
      + +
      +
      +
      +
      +

      Live MVP

      +

      Article Export Studio

      +
      + Ready +
      +
      +
      +
      +
      +
      +
      + + 2 affiliate links detected +
      +
      +
      amazon.com/... → your-store-id
      +
      partner.example/... → your-campaign
      +
      +
      +
      +
      + {exportOptions.map((format) => ( +
      + {format} +
      + ))} +
      +
      +
      +
      +
      + +
      +
      +
      + {workflowSteps.map((step, index) => ( +
      +
      + {index + 1} +
      +

      {step}

      +
      + ))} +
      +
      +
      + +
      +
      +
      +

      First workflow included

      +

      Customize links, preview the buyer version, and download WordPress XML, HTML, or Markdown.

      +
      + +
      +
      +
      + +
      +

      © 2026 LinkLift Studio. Built for affiliate article licensing.

      +
      + + Privacy Policy + + + + Login + +
      +
      ); } @@ -163,4 +161,3 @@ export default function Starter() { Starter.getLayout = function getLayout(page: ReactElement) { return {page}; }; -