Autosave: 20260602-211412
This commit is contained in:
parent
d904a9d09f
commit
f0df90f9b7
@ -1,7 +1,6 @@
|
|||||||
|
|
||||||
const db = require('../models');
|
const db = require('../models');
|
||||||
const FileDBApi = require('./file');
|
const FileDBApi = require('./file');
|
||||||
const crypto = require('crypto');
|
|
||||||
const Utils = require('../utils');
|
const Utils = require('../utils');
|
||||||
|
|
||||||
|
|
||||||
@ -382,10 +381,6 @@ module.exports = class ArticlesDBApi {
|
|||||||
|
|
||||||
offset = currentPage * limit;
|
offset = currentPage * limit;
|
||||||
|
|
||||||
const orderBy = null;
|
|
||||||
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
let include = [
|
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, ) {
|
static async findAllAutocomplete(query, limit, offset, ) {
|
||||||
let where = {};
|
let where = {};
|
||||||
|
|
||||||
|
|||||||
@ -100,10 +100,7 @@ const ArticlesData = [
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
"content_markdown": "# Best Budget Laptops for Remote Work
|
"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)",
|
||||||
Working from home is easier with the right laptop.
|
|
||||||
|
|
||||||
Top 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
|
"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)",
|
||||||
- [Standing Desk](https://example.com/affiliate/standing-desk?tag=ORIGINALTAG)
|
|
||||||
- [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
|
"content_markdown": "# Beginner Guide to Email Marketing Tools\nStarter option: [Email Platform X](https://example.com/affiliate/email-platform?ref=ORIGINALTAG)",
|
||||||
Starter option: [Email Platform X](https://example.com/affiliate/email-platform?ref=ORIGINALTAG)",
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -3345,7 +3339,7 @@ const ExportsData = [
|
|||||||
|
|
||||||
|
|
||||||
module.exports = {
|
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
|
* @swagger
|
||||||
* /api/articles/count:
|
* /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 Link from 'next/link'
|
||||||
import { useState } from 'react'
|
|
||||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||||
import BaseDivider from './BaseDivider'
|
import BaseDivider from './BaseDivider'
|
||||||
import BaseIcon from './BaseIcon'
|
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 React, { ReactNode, useEffect, useState } from 'react'
|
||||||
import { useState } from 'react'
|
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||||
import menuAside from '../menuAside'
|
import menuAside from '../menuAside'
|
||||||
|
|||||||
@ -8,6 +8,12 @@ const menuAside: MenuAsideItem[] = [
|
|||||||
label: 'Dashboard',
|
label: 'Dashboard',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
href: '/export-studio',
|
||||||
|
icon: icon.mdiFileSwapOutline,
|
||||||
|
label: 'Export Studio',
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
href: '/users/users-list',
|
href: '/users/users-list',
|
||||||
label: 'Users',
|
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 from 'react';
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { mdiArrowRight, mdiCheckCircleOutline, mdiDownloadBoxOutline, mdiFileSwapOutline, mdiLinkVariant } from '@mdi/js';
|
||||||
import BaseButton from '../components/BaseButton';
|
import BaseButton from '../components/BaseButton';
|
||||||
import CardBox from '../components/CardBox';
|
import BaseIcon from '../components/BaseIcon';
|
||||||
import SectionFullScreen from '../components/SectionFullScreen';
|
|
||||||
import LayoutGuest from '../layouts/Guest';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
import BaseDivider from '../components/BaseDivider';
|
|
||||||
import BaseButtons from '../components/BaseButtons';
|
|
||||||
import { getPageTitle } from '../config';
|
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() {
|
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 (
|
return (
|
||||||
<div
|
<div className="min-h-screen bg-[#F7F3EA] text-slate-950">
|
||||||
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',
|
|
||||||
}
|
|
||||||
: {}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Head>
|
<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>
|
</Head>
|
||||||
|
|
||||||
<SectionFullScreen bg='violet'>
|
<header className="sticky top-0 z-30 border-b border-white/60 bg-[#F7F3EA]/90 backdrop-blur-xl">
|
||||||
<div
|
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
|
||||||
className={`flex ${
|
<Link href="/" className="flex items-center gap-3 font-black tracking-tight text-slate-950">
|
||||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
<span className="grid h-10 w-10 place-items-center rounded-2xl bg-[#12332F] text-white shadow-lg shadow-emerald-950/20">
|
||||||
} min-h-screen w-full`}
|
<BaseIcon path={mdiFileSwapOutline} size="22" />
|
||||||
>
|
</span>
|
||||||
{contentType === 'image' && contentPosition !== 'background'
|
LinkLift Studio
|
||||||
? imageBlock(illustrationImage)
|
</Link>
|
||||||
: null}
|
<nav className="flex items-center gap-3 text-sm font-semibold">
|
||||||
{contentType === 'video' && contentPosition !== 'background'
|
<Link href="#workflow" className="hidden text-slate-600 transition hover:text-slate-950 sm:inline">
|
||||||
? videoBlock(illustrationVideo)
|
Workflow
|
||||||
: null}
|
</Link>
|
||||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
<Link href="#exports" className="hidden text-slate-600 transition hover:text-slate-950 sm:inline">
|
||||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
Exports
|
||||||
<CardBoxComponentTitle title="Welcome to your Affiliate Article Licensing app!"/>
|
</Link>
|
||||||
|
<BaseButton href="/login" label="Login / Admin" color="info" roundedFull />
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<main>
|
||||||
<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>
|
<section className="relative overflow-hidden px-6 py-16 sm:py-24">
|
||||||
<p className='text-center text-gray-500'>For guides and documentation please check
|
<div className="absolute left-1/2 top-10 h-96 w-96 -translate-x-1/2 rounded-full bg-[#1DBA8D]/20 blur-3xl" />
|
||||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
<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>
|
||||||
|
|
||||||
<BaseButtons>
|
<div className="relative z-10 rounded-[2rem] border border-white/80 bg-white p-4 shadow-2xl shadow-emerald-950/10">
|
||||||
<BaseButton
|
<div className="rounded-[1.5rem] bg-[#12332F] p-6 text-white">
|
||||||
href='/login'
|
<div className="mb-6 flex items-center justify-between">
|
||||||
label='Login'
|
<div>
|
||||||
color='info'
|
<p className="text-sm font-bold uppercase tracking-[0.25em] text-[#B7F8DA]">Live MVP</p>
|
||||||
className='w-full'
|
<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>
|
||||||
|
|
||||||
</BaseButtons>
|
<section id="workflow" className="px-6 py-16">
|
||||||
</CardBox>
|
<div className="mx-auto max-w-7xl">
|
||||||
</div>
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
</div>
|
{workflowSteps.map((step, index) => (
|
||||||
</SectionFullScreen>
|
<div key={step} className="rounded-[2rem] border border-white/70 bg-white/80 p-6 shadow-sm">
|
||||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
<div className="mb-5 grid h-12 w-12 place-items-center rounded-2xl bg-[#12332F] text-lg font-black text-white">
|
||||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
|
{index + 1}
|
||||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
</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
|
Privacy Policy
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -163,4 +161,3 @@ export default function Starter() {
|
|||||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||||
return <LayoutGuest>{page}</LayoutGuest>;
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user