Compare commits

..

2 Commits

Author SHA1 Message Date
Flatlogic Bot
407eb21fc4 V3 2026-06-02 22:31:33 +00:00
Flatlogic Bot
f0df90f9b7 Autosave: 20260602-211412 2026-06-02 21:14:07 +00:00
14 changed files with 1932 additions and 2228 deletions

View File

@ -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');
@ -71,19 +70,6 @@ module.exports = class ArticlesDBApi {
await articles.setAffiliate_links(data.affiliate_links || [], {
transaction,
});
await articles.setMedia_assets(data.media_assets || [], {
transaction,
});
await articles.setArticle_bundle_items(data.article_bundle_items || [], {
transaction,
});
await FileDBApi.replaceRelationFiles( await FileDBApi.replaceRelationFiles(
{ {
@ -215,24 +201,8 @@ module.exports = class ArticlesDBApi {
await articles.update(updatePayload, {transaction}); await articles.update(updatePayload, {transaction});
// Article source-content updates intentionally skip generated many-to-many
// affiliate/media/bundle sync. Those joins are handled later in Export Studio.
if (data.affiliate_links !== undefined) {
await articles.setAffiliate_links(data.affiliate_links, { transaction });
}
if (data.media_assets !== undefined) {
await articles.setMedia_assets(data.media_assets, { transaction });
}
if (data.article_bundle_items !== undefined) {
await articles.setArticle_bundle_items(data.article_bundle_items, { transaction });
}
await FileDBApi.replaceRelationFiles( await FileDBApi.replaceRelationFiles(
{ {
@ -348,22 +318,6 @@ module.exports = class ArticlesDBApi {
}); });
output.affiliate_links = await articles.getAffiliate_links({
transaction
});
output.media_assets = await articles.getMedia_assets({
transaction
});
output.article_bundle_items = await articles.getArticle_bundle_items({
transaction
});
return output; return output;
} }
@ -382,37 +336,11 @@ module.exports = class ArticlesDBApi {
offset = currentPage * limit; offset = currentPage * limit;
const orderBy = null;
const transaction = (options && options.transaction) || undefined;
let include = [ let include = [
{
model: db.affiliate_links,
as: 'affiliate_links',
required: false,
},
{
model: db.media_assets,
as: 'media_assets',
required: false,
},
{
model: db.article_bundle_items,
as: 'article_bundle_items',
required: false,
},
{ {
model: db.file, model: db.file,
as: 'featured_images', as: 'featured_images',
}, },
]; ];
if (filter) { if (filter) {
@ -538,76 +466,6 @@ module.exports = class ArticlesDBApi {
if (filter.affiliate_links) {
const searchTerms = filter.affiliate_links.split('|');
include = [
{
model: db.affiliate_links,
as: 'affiliate_links_filter',
required: searchTerms.length > 0,
where: searchTerms.length > 0 ? {
[Op.or]: [
{ id: { [Op.in]: searchTerms.map(term => Utils.uuid(term)) } },
{
original_url: {
[Op.or]: searchTerms.map(term => ({ [Op.iLike]: `%${term}%` }))
}
}
]
} : undefined
},
...include,
]
}
if (filter.media_assets) {
const searchTerms = filter.media_assets.split('|');
include = [
{
model: db.media_assets,
as: 'media_assets_filter',
required: searchTerms.length > 0,
where: searchTerms.length > 0 ? {
[Op.or]: [
{ id: { [Op.in]: searchTerms.map(term => Utils.uuid(term)) } },
{
file_name: {
[Op.or]: searchTerms.map(term => ({ [Op.iLike]: `%${term}%` }))
}
}
]
} : undefined
},
...include,
]
}
if (filter.article_bundle_items) {
const searchTerms = filter.article_bundle_items.split('|');
include = [
{
model: db.article_bundle_items,
as: 'article_bundle_items_filter',
required: searchTerms.length > 0,
where: searchTerms.length > 0 ? {
[Op.or]: [
{ id: { [Op.in]: searchTerms.map(term => Utils.uuid(term)) } },
{
sort_order: {
[Op.or]: searchTerms.map(term => ({ [Op.iLike]: `%${term}%` }))
}
}
]
} : undefined
},
...include,
]
}
if (filter.createdAtRange) { if (filter.createdAtRange) {
const [start, end] = filter.createdAtRange; const [start, end] = filter.createdAtRange;
@ -665,6 +523,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 = {};

View File

@ -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) => {

View File

@ -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:

View File

@ -1,272 +1,91 @@
import React from 'react'; import React from 'react'
import BaseIcon from '../BaseIcon'; import { GridRowParams } from '@mui/x-data-grid'
import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; import ListActionsPopover from '../ListActionsPopover'
import axios from 'axios'; import { hasPermission } from '../../helpers/userPermissions'
import {
GridActionsCellItem,
GridRowParams,
GridValueGetterParams,
} from '@mui/x-data-grid';
import ImageField from '../ImageField';
import {saveFile} from "../../helpers/fileSaver";
import dataFormatter from '../../helpers/dataFormatter'
import DataGridMultiSelect from "../DataGridMultiSelect";
import ListActionsPopover from '../ListActionsPopover';
import {hasPermission} from "../../helpers/userPermissions"; type Params = (id: string) => void
type Params = (id: string) => void;
export const loadColumns = async (
onDelete: Params,
entityName: string,
user
) => {
async function callOptionsApi(entityName: string) {
if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return [];
try {
const data = await axios(`/${entityName}/autocomplete?limit=100`);
return data.data;
} catch (error) {
console.log(error);
return [];
}
}
export const loadColumns = async (onDelete: Params, _entityName: string, user) => {
const hasUpdatePermission = hasPermission(user, 'UPDATE_ARTICLES') const hasUpdatePermission = hasPermission(user, 'UPDATE_ARTICLES')
return [ return [
{ {
field: 'title', field: 'title',
headerName: 'Title', headerName: 'Title',
flex: 1, flex: 1,
minWidth: 120, minWidth: 180,
filterable: false, filterable: false,
headerClassName: 'datagrid--header', headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell', cellClassName: 'datagrid--cell',
editable: hasUpdatePermission, editable: hasUpdatePermission,
}, },
{
field: 'slug',
headerName: 'Slug',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{ {
field: 'status', field: 'status',
headerName: 'Status', headerName: 'Status',
flex: 1, flex: 0.6,
minWidth: 120, minWidth: 120,
filterable: false, filterable: false,
headerClassName: 'datagrid--header', headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell', cellClassName: 'datagrid--cell',
editable: hasUpdatePermission, editable: hasUpdatePermission,
}, },
{ {
field: 'excerpt', field: 'excerpt',
headerName: 'Excerpt', headerName: 'Excerpt',
flex: 1, flex: 1.2,
minWidth: 120, minWidth: 220,
filterable: false, filterable: false,
headerClassName: 'datagrid--header', headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell', cellClassName: 'datagrid--cell',
editable: hasUpdatePermission, editable: hasUpdatePermission,
}, },
{ {
field: 'content_html', field: 'content_html',
headerName: 'ContentHTML', headerName: 'HTML Content',
flex: 1, flex: 1.2,
minWidth: 120, minWidth: 220,
filterable: false, filterable: false,
headerClassName: 'datagrid--header', headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell', cellClassName: 'datagrid--cell',
editable: hasUpdatePermission, editable: hasUpdatePermission,
}, },
{ {
field: 'content_markdown', field: 'content_markdown',
headerName: 'ContentMarkdown', headerName: 'Meta Paste',
flex: 1, flex: 1,
minWidth: 120, minWidth: 180,
filterable: false, filterable: false,
headerClassName: 'datagrid--header', headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell', cellClassName: 'datagrid--cell',
editable: hasUpdatePermission, editable: hasUpdatePermission,
}, },
{
field: 'featured_images',
headerName: 'FeaturedImages',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: false,
sortable: false,
renderCell: (params: GridValueGetterParams) => (
<ImageField
name={'Avatar'}
image={params?.row?.featured_images}
className='w-24 h-24 mx-auto lg:w-6 lg:h-6'
/>
),
},
{ {
field: 'source_url', field: 'source_url',
headerName: 'Source URL', headerName: 'Source URL',
flex: 1, flex: 1,
minWidth: 120, minWidth: 180,
filterable: false, filterable: false,
headerClassName: 'datagrid--header', headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell', cellClassName: 'datagrid--cell',
editable: hasUpdatePermission, editable: hasUpdatePermission,
}, },
{
field: 'published_at',
headerName: 'PublishedAt',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'dateTime',
valueGetter: (params: GridValueGetterParams) =>
new Date(params.row.published_at),
},
{
field: 'affiliate_links',
headerName: 'AffiliateLinks',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: false,
sortable: false,
type: 'singleSelect',
valueFormatter: ({ value }) =>
dataFormatter.affiliate_linksManyListFormatter(value).join(', '),
renderEditCell: (params) => (
<DataGridMultiSelect {...params} entityName={'affiliate_links'}/>
),
},
{
field: 'media_assets',
headerName: 'MediaAssets',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: false,
sortable: false,
type: 'singleSelect',
valueFormatter: ({ value }) =>
dataFormatter.media_assetsManyListFormatter(value).join(', '),
renderEditCell: (params) => (
<DataGridMultiSelect {...params} entityName={'media_assets'}/>
),
},
{
field: 'article_bundle_items',
headerName: 'ArticleBundleItems',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: false,
sortable: false,
type: 'singleSelect',
valueFormatter: ({ value }) =>
dataFormatter.article_bundle_itemsManyListFormatter(value).join(', '),
renderEditCell: (params) => (
<DataGridMultiSelect {...params} entityName={'article_bundle_items'}/>
),
},
{ {
field: 'actions', field: 'actions',
type: 'actions', type: 'actions',
minWidth: 30, minWidth: 30,
headerClassName: 'datagrid--header', headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell', cellClassName: 'datagrid--cell',
getActions: (params: GridRowParams) => { getActions: (params: GridRowParams) => [
return [
<div key={params?.row?.id}> <div key={params?.row?.id}>
<ListActionsPopover <ListActionsPopover
onDelete={onDelete} onDelete={onDelete}
itemId={params?.row?.id} itemId={params?.row?.id}
pathEdit={`/articles/articles-edit/?id=${params?.row?.id}`} pathEdit={`/articles/articles-edit/?id=${params?.row?.id}`}
pathView={`/articles/articles-view/?id=${params?.row?.id}`} pathView={`/articles/articles-view/?id=${params?.row?.id}`}
hasUpdatePermission={hasUpdatePermission} hasUpdatePermission={hasUpdatePermission}
/> />
</div>, </div>,
],
},
] ]
}, }
},
];
};

View File

@ -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'

View 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(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&apos;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/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('&amp;')) {
return updatedUrl.replace(/&/g, '&amp;');
}
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;
};

View File

@ -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'

View File

@ -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',

File diff suppressed because it is too large Load Diff

View File

@ -34,13 +34,13 @@ const ArticlesTablesPage = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [filters] = useState([{label: 'Title', title: 'title'},{label: 'Slug', title: 'slug'},{label: 'Excerpt', title: 'excerpt'},{label: 'ContentHTML', title: 'content_html'},{label: 'ContentMarkdown', title: 'content_markdown'},{label: 'SourceURL', title: 'source_url'}, const [filters] = useState([
{ label: 'Title', title: 'title' },
{label: 'PublishedAt', title: 'published_at', date: 'true'},
{label: 'AffiliateLinks', title: 'affiliate_links'},{label: 'MediaAssets', title: 'media_assets'},{label: 'ArticleBundleItems', title: 'article_bundle_items'},
{ label: 'Status', title: 'status', type: 'enum', options: ['draft', 'published', 'archived'] }, { label: 'Status', title: 'status', type: 'enum', options: ['draft', 'published', 'archived'] },
{ label: 'Excerpt', title: 'excerpt' },
{ label: 'HTML Content', title: 'content_html' },
{ label: 'Meta Paste', title: 'content_markdown' },
{ label: 'Source URL', title: 'source_url' },
]); ]);
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_ARTICLES'); const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_ARTICLES');
@ -94,7 +94,7 @@ const ArticlesTablesPage = () => {
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'> <CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/articles/articles-new'} color='info' label='New Item'/>} {hasCreatePermission && <BaseButton className={'mr-3'} href={'/articles/articles-new'} color='info' label='New Article'/>}
<BaseButton <BaseButton
className={'mr-3'} className={'mr-3'}

View File

@ -1,691 +1,128 @@
import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js' import { mdiChartTimelineVariant } from '@mdi/js'
import Head from 'next/head' import Head from 'next/head'
import React, { ReactElement } from 'react' import React, { ReactElement, useState } from 'react'
import { Field, Form, Formik } from 'formik'
import { useRouter } from 'next/router'
import BaseButton from '../../components/BaseButton'
import BaseButtons from '../../components/BaseButtons'
import BaseDivider from '../../components/BaseDivider'
import CardBox from '../../components/CardBox' import CardBox from '../../components/CardBox'
import LayoutAuthenticated from '../../layouts/Authenticated' import FormField from '../../components/FormField'
import { RichTextField } from '../../components/RichTextField'
import SectionMain from '../../components/SectionMain' import SectionMain from '../../components/SectionMain'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton' import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
import LayoutAuthenticated from '../../layouts/Authenticated'
import { getPageTitle } from '../../config' import { getPageTitle } from '../../config'
import { Field, Form, Formik } from 'formik'
import FormField from '../../components/FormField'
import BaseDivider from '../../components/BaseDivider'
import BaseButtons from '../../components/BaseButtons'
import BaseButton from '../../components/BaseButton'
import FormCheckRadio from '../../components/FormCheckRadio'
import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'
import FormFilePicker from '../../components/FormFilePicker'
import FormImagePicker from '../../components/FormImagePicker'
import { SwitchField } from '../../components/SwitchField'
import { SelectField } from '../../components/SelectField'
import { SelectFieldMany } from "../../components/SelectFieldMany";
import {RichTextField} from "../../components/RichTextField";
import { create } from '../../stores/articles/articlesSlice' import { create } from '../../stores/articles/articlesSlice'
import { useAppDispatch } from '../../stores/hooks' import { useAppDispatch } from '../../stores/hooks'
import { useRouter } from 'next/router'
import moment from 'moment';
const initialValues = { const initialValues = {
title: '', title: '',
slug: '',
status: 'draft', status: 'draft',
excerpt: '', excerpt: '',
content_html: '', content_html: '',
content_markdown: '', content_markdown: '',
featured_images: [],
source_url: '', source_url: '',
published_at: '',
affiliate_links: [],
media_assets: [],
article_bundle_items: [],
} }
const getErrorMessage = (error: any) => {
if (!error) return 'Article could not be saved. Please check the form and try again.'
if (typeof error === 'string') return error
if (error.message) return error.message
if (error.error) return error.error
return 'Article could not be saved. Please check the form and try again.'
}
const ArticlesNew = () => { const ArticlesNew = () => {
const router = useRouter() const router = useRouter()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const [submitError, setSubmitError] = useState('')
const handleSubmit = async (data, { setSubmitting }) => {
setSubmitError('')
try {
await dispatch(create(data)).unwrap()
const handleSubmit = async (data) => {
await dispatch(create(data))
await router.push('/articles/articles-list') await router.push('/articles/articles-list')
} catch (error) {
console.error('Article create failed:', error)
setSubmitError(getErrorMessage(error))
} finally {
setSubmitting(false)
} }
}
return ( return (
<> <>
<Head> <Head>
<title>{getPageTitle('New Item')}</title> <title>{getPageTitle('New Article')}</title>
</Head> </Head>
<SectionMain> <SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main> <SectionTitleLineWithButton icon={mdiChartTimelineVariant} title='New Article' main>
{''} {''}
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<CardBox> <CardBox>
<Formik <Formik initialValues={initialValues} onSubmit={handleSubmit}>
initialValues={ {({ isSubmitting }) => (
initialValues
}
onSubmit={(values) => handleSubmit(values)}
>
<Form> <Form>
<p className='mb-4 text-sm text-gray-600 dark:text-dark-600'>
Add the source article here. Buyer-specific affiliate link replacement happens later in Export Studio.
</p>
{submitError && (
<div className='mb-4 rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700'>
{submitError}
</div>
)}
<FormField label='Title'>
<FormField <Field name='title' placeholder='Article title' />
label="Title"
>
<Field
name="title"
placeholder="Title"
/>
</FormField> </FormField>
<FormField label='Status' labelFor='status'>
<Field name='status' id='status' component='select'>
<option value='draft'>Draft</option>
<option value='published'>Published</option>
<option value='archived'>Archived</option>
<FormField
label="Slug"
>
<Field
name="slug"
placeholder="Slug"
/>
</FormField>
<FormField label="Status" labelFor="status">
<Field name="status" id="status" component="select">
<option value="draft">draft</option>
<option value="published">published</option>
<option value="archived">archived</option>
</Field> </Field>
</FormField> </FormField>
<FormField label='Excerpt' hasTextareaHeight>
<Field name='excerpt' as='textarea' placeholder='Optional short summary for the article list/export screen' />
<FormField label="Excerpt" hasTextareaHeight>
<Field name="excerpt" as="textarea" placeholder="Excerpt" />
</FormField> </FormField>
<FormField label='HTML Content' hasTextareaHeight>
<Field name='content_html' id='content_html' component={RichTextField} />
</FormField>
<FormField label='Meta Paste' hasTextareaHeight>
<FormField label='ContentHTML' hasTextareaHeight>
<Field <Field
name='content_html' name='content_markdown'
id='content_html' as='textarea'
component={RichTextField} placeholder='Paste your metadata, notes, markdown, or source details here'
></Field>
</FormField>
<FormField label="ContentMarkdown" hasTextareaHeight>
<Field name="content_markdown" as="textarea" placeholder="ContentMarkdown" />
</FormField>
<FormField>
<Field
label='FeaturedImages'
color='info'
icon={mdiUpload}
path={'articles/featured_images'}
name='featured_images'
id='featured_images'
schema={{
size: undefined,
formats: undefined,
}}
component={FormImagePicker}
></Field>
</FormField>
<FormField
label="SourceURL"
>
<Field
name="source_url"
placeholder="SourceURL"
/> />
</FormField> </FormField>
<FormField label='Source URL'>
<Field name='source_url' placeholder='Optional source URL' />
<FormField
label="PublishedAt"
>
<Field
type="datetime-local"
name="published_at"
placeholder="PublishedAt"
/>
</FormField> </FormField>
<FormField label='AffiliateLinks' labelFor='affiliate_links'>
<Field
name='affiliate_links'
id='affiliate_links'
itemRef={'affiliate_links'}
options={[]}
component={SelectFieldMany}>
</Field>
</FormField>
<FormField label='MediaAssets' labelFor='media_assets'>
<Field
name='media_assets'
id='media_assets'
itemRef={'media_assets'}
options={[]}
component={SelectFieldMany}>
</Field>
</FormField>
<FormField label='ArticleBundleItems' labelFor='article_bundle_items'>
<Field
name='article_bundle_items'
id='article_bundle_items'
itemRef={'article_bundle_items'}
options={[]}
component={SelectFieldMany}>
</Field>
</FormField>
<BaseDivider /> <BaseDivider />
<BaseButtons> <BaseButtons>
<BaseButton type="submit" color="info" label="Submit" /> <BaseButton type='submit' color='info' label={isSubmitting ? 'Saving...' : 'Save Article'} disabled={isSubmitting} />
<BaseButton type="reset" color="info" outline label="Reset" /> <BaseButton type='reset' color='info' outline label='Reset' disabled={isSubmitting} />
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/articles/articles-list')}/> <BaseButton
type='button'
color='danger'
outline
label='Cancel'
onClick={() => router.push('/articles/articles-list')}
disabled={isSubmitting}
/>
</BaseButtons> </BaseButtons>
</Form> </Form>
)}
</Formik> </Formik>
</CardBox> </CardBox>
</SectionMain> </SectionMain>
@ -694,15 +131,7 @@ const ArticlesNew = () => {
} }
ArticlesNew.getLayout = function getLayout(page: ReactElement) { ArticlesNew.getLayout = function getLayout(page: ReactElement) {
return ( return <LayoutAuthenticated permission='CREATE_ARTICLES'>{page}</LayoutAuthenticated>
<LayoutAuthenticated
permission={'CREATE_ARTICLES'}
>
{page}
</LayoutAuthenticated>
)
} }
export default ArticlesNew export default ArticlesNew

View File

@ -34,13 +34,13 @@ const ArticlesTablesPage = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [filters] = useState([{label: 'Title', title: 'title'},{label: 'Slug', title: 'slug'},{label: 'Excerpt', title: 'excerpt'},{label: 'ContentHTML', title: 'content_html'},{label: 'ContentMarkdown', title: 'content_markdown'},{label: 'SourceURL', title: 'source_url'}, const [filters] = useState([
{ label: 'Title', title: 'title' },
{label: 'PublishedAt', title: 'published_at', date: 'true'},
{label: 'AffiliateLinks', title: 'affiliate_links'},{label: 'MediaAssets', title: 'media_assets'},{label: 'ArticleBundleItems', title: 'article_bundle_items'},
{ label: 'Status', title: 'status', type: 'enum', options: ['draft', 'published', 'archived'] }, { label: 'Status', title: 'status', type: 'enum', options: ['draft', 'published', 'archived'] },
{ label: 'Excerpt', title: 'excerpt' },
{ label: 'HTML Content', title: 'content_html' },
{ label: 'Meta Paste', title: 'content_markdown' },
{ label: 'Source URL', title: 'source_url' },
]); ]);
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_ARTICLES'); const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_ARTICLES');
@ -94,7 +94,7 @@ const ArticlesTablesPage = () => {
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'> <CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/articles/articles-new'} color='info' label='New Item'/>} {hasCreatePermission && <BaseButton className={'mr-3'} href={'/articles/articles-new'} color='info' label='New Article'/>}
<BaseButton <BaseButton
className={'mr-3'} className={'mr-3'}

File diff suppressed because it is too large Load Diff

View File

@ -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 ( return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'> <div className="min-h-screen bg-[#F7F3EA] text-slate-950">
<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',
}
: {}
}
>
<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 />
<div className="space-y-3"> </nav>
<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> </div>
</header>
<BaseButtons> <main>
<BaseButton <section className="relative overflow-hidden px-6 py-16 sm:py-24">
href='/login' <div className="absolute left-1/2 top-10 h-96 w-96 -translate-x-1/2 rounded-full bg-[#1DBA8D]/20 blur-3xl" />
label='Login' <div className="mx-auto grid max-w-7xl items-center gap-12 lg:grid-cols-[1.05fr_0.95fr]">
color='info' <div className="relative z-10">
className='w-full' <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
</BaseButtons> </div>
</CardBox> <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> </div>
</SectionFullScreen>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'> <div className="relative z-10 rounded-[2rem] border border-white/80 bg-white p-4 shadow-2xl shadow-emerald-950/10">
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p> <div className="rounded-[1.5rem] bg-[#12332F] p-6 text-white">
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'> <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 Privacy Policy
</Link> </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> </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>;
}; };