Autosave: 20260602-211412
This commit is contained in:
parent
d904a9d09f
commit
f0df90f9b7
@ -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 = {};
|
||||
|
||||
|
||||
@ -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) => {
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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'
|
||||
|
||||
389
frontend/src/helpers/affiliateLinks.ts
Normal file
389
frontend/src/helpers/affiliateLinks.ts
Normal file
@ -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<string>();
|
||||
|
||||
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 =
|
||||
/<a\b[^>]*?\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<string, AffiliateReplacementGroup>();
|
||||
|
||||
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<string, string>,
|
||||
urlOverrides: Record<string, string>,
|
||||
) => {
|
||||
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;
|
||||
};
|
||||
@ -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'
|
||||
|
||||
@ -8,6 +8,12 @@ const menuAside: MenuAsideItem[] = [
|
||||
label: 'Dashboard',
|
||||
},
|
||||
|
||||
{
|
||||
href: '/export-studio',
|
||||
icon: icon.mdiFileSwapOutline,
|
||||
label: 'Export Studio',
|
||||
},
|
||||
|
||||
{
|
||||
href: '/users/users-list',
|
||||
label: 'Users',
|
||||
|
||||
1002
frontend/src/pages/export-studio.tsx
Normal file
1002
frontend/src/pages/export-studio.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -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) => (
|
||||
<div
|
||||
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
|
||||
style={{
|
||||
backgroundImage: `${
|
||||
image
|
||||
? `url(${image?.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
>
|
||||
<div className='flex justify-center w-full bg-blue-300/20'>
|
||||
<a
|
||||
className='text-[8px]'
|
||||
href={image?.photographer_url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Photo by {image?.photographer} on Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const videoBlock = (video) => {
|
||||
if (video?.video_files?.length > 0) {
|
||||
return (
|
||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
||||
<video
|
||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
>
|
||||
<source src={video?.video_files[0]?.link} type='video/mp4'/>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
||||
<a
|
||||
className='text-[8px]'
|
||||
href={video?.user?.url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Video by {video.user.name} on Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>)
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
contentPosition === 'background'
|
||||
? {
|
||||
backgroundImage: `${
|
||||
illustrationImage
|
||||
? `url(${illustrationImage.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<div className="min-h-screen bg-[#F7F3EA] text-slate-950">
|
||||
<Head>
|
||||
<title>{getPageTitle('Starter Page')}</title>
|
||||
<title>{getPageTitle('Affiliate Article Licensing')}</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="License affiliate article bundles, let buyers replace links, and export WordPress-ready content."
|
||||
/>
|
||||
</Head>
|
||||
|
||||
<SectionFullScreen bg='violet'>
|
||||
<div
|
||||
className={`flex ${
|
||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
||||
} min-h-screen w-full`}
|
||||
>
|
||||
{contentType === 'image' && contentPosition !== 'background'
|
||||
? imageBlock(illustrationImage)
|
||||
: null}
|
||||
{contentType === 'video' && contentPosition !== 'background'
|
||||
? videoBlock(illustrationVideo)
|
||||
: null}
|
||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
||||
<CardBoxComponentTitle title="Welcome to your Affiliate Article Licensing app!"/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
|
||||
<p className='text-center text-gray-500'>For guides and documentation please check
|
||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
||||
</div>
|
||||
|
||||
<BaseButtons>
|
||||
<BaseButton
|
||||
href='/login'
|
||||
label='Login'
|
||||
color='info'
|
||||
className='w-full'
|
||||
/>
|
||||
|
||||
</BaseButtons>
|
||||
</CardBox>
|
||||
<header className="sticky top-0 z-30 border-b border-white/60 bg-[#F7F3EA]/90 backdrop-blur-xl">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
|
||||
<Link href="/" className="flex items-center gap-3 font-black tracking-tight text-slate-950">
|
||||
<span className="grid h-10 w-10 place-items-center rounded-2xl bg-[#12332F] text-white shadow-lg shadow-emerald-950/20">
|
||||
<BaseIcon path={mdiFileSwapOutline} size="22" />
|
||||
</span>
|
||||
LinkLift Studio
|
||||
</Link>
|
||||
<nav className="flex items-center gap-3 text-sm font-semibold">
|
||||
<Link href="#workflow" className="hidden text-slate-600 transition hover:text-slate-950 sm:inline">
|
||||
Workflow
|
||||
</Link>
|
||||
<Link href="#exports" className="hidden text-slate-600 transition hover:text-slate-950 sm:inline">
|
||||
Exports
|
||||
</Link>
|
||||
<BaseButton href="/login" label="Login / Admin" color="info" roundedFull />
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</SectionFullScreen>
|
||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
|
||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section className="relative overflow-hidden px-6 py-16 sm:py-24">
|
||||
<div className="absolute left-1/2 top-10 h-96 w-96 -translate-x-1/2 rounded-full bg-[#1DBA8D]/20 blur-3xl" />
|
||||
<div className="mx-auto grid max-w-7xl items-center gap-12 lg:grid-cols-[1.05fr_0.95fr]">
|
||||
<div className="relative z-10">
|
||||
<div className="mb-6 inline-flex items-center gap-2 rounded-full border border-[#12332F]/10 bg-white/70 px-4 py-2 text-sm font-bold text-[#12332F] shadow-sm">
|
||||
<BaseIcon path={mdiLinkVariant} size="18" />
|
||||
Affiliate content bundles for marketers
|
||||
</div>
|
||||
<h1 className="max-w-4xl text-5xl font-black leading-tight tracking-tight text-slate-950 sm:text-6xl lg:text-7xl">
|
||||
Sell article bundles that buyers can personalize in minutes.
|
||||
</h1>
|
||||
<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-650 text-slate-700">
|
||||
Upload WordPress-ready articles once. Buyers log in, replace your affiliate links with theirs, and download a file they can import or publish fast.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
|
||||
<BaseButton href="/login" label="Open buyer portal" color="info" roundedFull className="shadow-xl shadow-blue-900/20" />
|
||||
<BaseButton href="/login" label="Admin interface" color="contrast" outline roundedFull />
|
||||
</div>
|
||||
<div className="mt-8 grid max-w-xl grid-cols-3 gap-3 text-center">
|
||||
{[
|
||||
['3', 'export formats'],
|
||||
['2 min', 'buyer setup'],
|
||||
['0', 'plugin required'],
|
||||
].map(([value, label]) => (
|
||||
<div key={label} className="rounded-3xl border border-white/70 bg-white/70 p-4 shadow-sm">
|
||||
<div className="text-2xl font-black text-[#12332F]">{value}</div>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-slate-500">{label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 rounded-[2rem] border border-white/80 bg-white p-4 shadow-2xl shadow-emerald-950/10">
|
||||
<div className="rounded-[1.5rem] bg-[#12332F] p-6 text-white">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-bold uppercase tracking-[0.25em] text-[#B7F8DA]">Live MVP</p>
|
||||
<h2 className="mt-2 text-2xl font-black">Article Export Studio</h2>
|
||||
</div>
|
||||
<span className="rounded-full bg-[#F4A33D] px-3 py-1 text-xs font-black text-[#12332F]">Ready</span>
|
||||
</div>
|
||||
<div className="space-y-3 rounded-3xl bg-white/10 p-4">
|
||||
<div className="h-3 w-2/3 rounded-full bg-white/80" />
|
||||
<div className="h-3 w-full rounded-full bg-white/30" />
|
||||
<div className="h-3 w-5/6 rounded-full bg-white/30" />
|
||||
<div className="mt-5 rounded-2xl border border-[#B7F8DA]/30 bg-[#B7F8DA]/10 p-4">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-bold text-[#B7F8DA]">
|
||||
<BaseIcon path={mdiCheckCircleOutline} size="18" />
|
||||
2 affiliate links detected
|
||||
</div>
|
||||
<div className="space-y-2 text-xs text-white/80">
|
||||
<div className="rounded-xl bg-black/20 p-3">amazon.com/... → your-store-id</div>
|
||||
<div className="rounded-xl bg-black/20 p-3">partner.example/... → your-campaign</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-3 gap-2">
|
||||
{exportOptions.map((format) => (
|
||||
<div key={format} className="rounded-2xl bg-white px-3 py-3 text-center text-xs font-black text-[#12332F]">
|
||||
{format}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="workflow" className="px-6 py-16">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{workflowSteps.map((step, index) => (
|
||||
<div key={step} className="rounded-[2rem] border border-white/70 bg-white/80 p-6 shadow-sm">
|
||||
<div className="mb-5 grid h-12 w-12 place-items-center rounded-2xl bg-[#12332F] text-lg font-black text-white">
|
||||
{index + 1}
|
||||
</div>
|
||||
<p className="text-lg font-bold leading-7 text-slate-800">{step}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="exports" className="bg-[#12332F] px-6 py-16 text-white">
|
||||
<div className="mx-auto flex max-w-7xl flex-col items-start justify-between gap-8 lg:flex-row lg:items-center">
|
||||
<div>
|
||||
<p className="font-bold uppercase tracking-[0.25em] text-[#B7F8DA]">First workflow included</p>
|
||||
<h2 className="mt-3 max-w-3xl text-4xl font-black tracking-tight">Customize links, preview the buyer version, and download WordPress XML, HTML, or Markdown.</h2>
|
||||
</div>
|
||||
<BaseButton href="/login" label="Start after login" icon={mdiArrowRight} color="warning" roundedFull />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer className="flex flex-col items-center justify-between gap-4 bg-slate-950 px-6 py-8 text-sm text-white sm:flex-row">
|
||||
<p>© 2026 LinkLift Studio. Built for affiliate article licensing.</p>
|
||||
<div className="flex gap-5">
|
||||
<Link href="/privacy-policy/" className="text-white/70 hover:text-white">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
<Link href="/login" className="inline-flex items-center gap-2 font-bold text-[#B7F8DA] hover:text-white">
|
||||
<BaseIcon path={mdiDownloadBoxOutline} size="16" />
|
||||
Login
|
||||
</Link>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -163,4 +161,3 @@ export default function Starter() {
|
||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user