Compare commits
No commits in common. "ai-dev" and "master" have entirely different histories.
@ -1,6 +1,7 @@
|
||||
|
||||
const db = require('../models');
|
||||
const FileDBApi = require('./file');
|
||||
const crypto = require('crypto');
|
||||
const Utils = require('../utils');
|
||||
|
||||
|
||||
@ -70,6 +71,19 @@ 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(
|
||||
{
|
||||
@ -201,9 +215,25 @@ module.exports = class ArticlesDBApi {
|
||||
|
||||
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(
|
||||
{
|
||||
belongsTo: db.articles.getTableName(),
|
||||
@ -318,6 +348,22 @@ 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;
|
||||
}
|
||||
|
||||
@ -336,11 +382,37 @@ module.exports = class ArticlesDBApi {
|
||||
|
||||
offset = currentPage * limit;
|
||||
|
||||
const orderBy = null;
|
||||
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
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,
|
||||
as: 'featured_images',
|
||||
},
|
||||
|
||||
];
|
||||
|
||||
if (filter) {
|
||||
@ -466,6 +538,76 @@ 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) {
|
||||
const [start, end] = filter.createdAtRange;
|
||||
|
||||
@ -523,41 +665,6 @@ 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,7 +100,10 @@ const ArticlesData = [
|
||||
|
||||
|
||||
|
||||
"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)",
|
||||
"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)",
|
||||
|
||||
|
||||
|
||||
@ -188,7 +191,9 @@ const ArticlesData = [
|
||||
|
||||
|
||||
|
||||
"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)",
|
||||
"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)",
|
||||
|
||||
|
||||
|
||||
@ -276,7 +281,8 @@ const ArticlesData = [
|
||||
|
||||
|
||||
|
||||
"content_markdown": "# Beginner Guide to Email Marketing Tools\nStarter option: [Email Platform X](https://example.com/affiliate/email-platform?ref=ORIGINALTAG)",
|
||||
"content_markdown": "# Beginner Guide to Email Marketing Tools
|
||||
Starter option: [Email Platform X](https://example.com/affiliate/email-platform?ref=ORIGINALTAG)",
|
||||
|
||||
|
||||
|
||||
@ -3339,7 +3345,7 @@ const ExportsData = [
|
||||
|
||||
|
||||
module.exports = {
|
||||
up: async () => {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
|
||||
|
||||
|
||||
@ -3736,7 +3742,7 @@ module.exports = {
|
||||
|
||||
},
|
||||
|
||||
down: async (queryInterface) => {
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
|
||||
|
||||
|
||||
|
||||
@ -324,16 +324,6 @@ 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,91 +1,272 @@
|
||||
import React from 'react'
|
||||
import { GridRowParams } from '@mui/x-data-grid'
|
||||
import ListActionsPopover from '../ListActionsPopover'
|
||||
import { hasPermission } from '../../helpers/userPermissions'
|
||||
import React from 'react';
|
||||
import BaseIcon from '../BaseIcon';
|
||||
import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js';
|
||||
import axios from 'axios';
|
||||
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';
|
||||
|
||||
type Params = (id: string) => void
|
||||
import {hasPermission} from "../../helpers/userPermissions";
|
||||
|
||||
export const loadColumns = async (onDelete: Params, _entityName: string, user) => {
|
||||
const hasUpdatePermission = hasPermission(user, 'UPDATE_ARTICLES')
|
||||
type Params = (id: string) => void;
|
||||
|
||||
return [
|
||||
{
|
||||
field: 'title',
|
||||
headerName: 'Title',
|
||||
flex: 1,
|
||||
minWidth: 180,
|
||||
filterable: false,
|
||||
headerClassName: 'datagrid--header',
|
||||
cellClassName: 'datagrid--cell',
|
||||
editable: hasUpdatePermission,
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
headerName: 'Status',
|
||||
flex: 0.6,
|
||||
minWidth: 120,
|
||||
filterable: false,
|
||||
headerClassName: 'datagrid--header',
|
||||
cellClassName: 'datagrid--cell',
|
||||
editable: hasUpdatePermission,
|
||||
},
|
||||
{
|
||||
field: 'excerpt',
|
||||
headerName: 'Excerpt',
|
||||
flex: 1.2,
|
||||
minWidth: 220,
|
||||
filterable: false,
|
||||
headerClassName: 'datagrid--header',
|
||||
cellClassName: 'datagrid--cell',
|
||||
editable: hasUpdatePermission,
|
||||
},
|
||||
{
|
||||
field: 'content_html',
|
||||
headerName: 'HTML Content',
|
||||
flex: 1.2,
|
||||
minWidth: 220,
|
||||
filterable: false,
|
||||
headerClassName: 'datagrid--header',
|
||||
cellClassName: 'datagrid--cell',
|
||||
editable: hasUpdatePermission,
|
||||
},
|
||||
{
|
||||
field: 'content_markdown',
|
||||
headerName: 'Meta Paste',
|
||||
flex: 1,
|
||||
minWidth: 180,
|
||||
filterable: false,
|
||||
headerClassName: 'datagrid--header',
|
||||
cellClassName: 'datagrid--cell',
|
||||
editable: hasUpdatePermission,
|
||||
},
|
||||
{
|
||||
field: 'source_url',
|
||||
headerName: 'Source URL',
|
||||
flex: 1,
|
||||
minWidth: 180,
|
||||
filterable: false,
|
||||
headerClassName: 'datagrid--header',
|
||||
cellClassName: 'datagrid--cell',
|
||||
editable: hasUpdatePermission,
|
||||
},
|
||||
{
|
||||
field: 'actions',
|
||||
type: 'actions',
|
||||
minWidth: 30,
|
||||
headerClassName: 'datagrid--header',
|
||||
cellClassName: 'datagrid--cell',
|
||||
getActions: (params: GridRowParams) => [
|
||||
<div key={params?.row?.id}>
|
||||
<ListActionsPopover
|
||||
onDelete={onDelete}
|
||||
itemId={params?.row?.id}
|
||||
pathEdit={`/articles/articles-edit/?id=${params?.row?.id}`}
|
||||
pathView={`/articles/articles-view/?id=${params?.row?.id}`}
|
||||
hasUpdatePermission={hasUpdatePermission}
|
||||
/>
|
||||
</div>,
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
||||
const hasUpdatePermission = hasPermission(user, 'UPDATE_ARTICLES')
|
||||
|
||||
return [
|
||||
|
||||
{
|
||||
field: 'title',
|
||||
headerName: 'Title',
|
||||
flex: 1,
|
||||
minWidth: 120,
|
||||
filterable: false,
|
||||
headerClassName: 'datagrid--header',
|
||||
cellClassName: 'datagrid--cell',
|
||||
|
||||
|
||||
editable: hasUpdatePermission,
|
||||
|
||||
|
||||
},
|
||||
|
||||
{
|
||||
field: 'slug',
|
||||
headerName: 'Slug',
|
||||
flex: 1,
|
||||
minWidth: 120,
|
||||
filterable: false,
|
||||
headerClassName: 'datagrid--header',
|
||||
cellClassName: 'datagrid--cell',
|
||||
|
||||
|
||||
editable: hasUpdatePermission,
|
||||
|
||||
|
||||
},
|
||||
|
||||
{
|
||||
field: 'status',
|
||||
headerName: 'Status',
|
||||
flex: 1,
|
||||
minWidth: 120,
|
||||
filterable: false,
|
||||
headerClassName: 'datagrid--header',
|
||||
cellClassName: 'datagrid--cell',
|
||||
|
||||
|
||||
editable: hasUpdatePermission,
|
||||
|
||||
|
||||
},
|
||||
|
||||
{
|
||||
field: 'excerpt',
|
||||
headerName: 'Excerpt',
|
||||
flex: 1,
|
||||
minWidth: 120,
|
||||
filterable: false,
|
||||
headerClassName: 'datagrid--header',
|
||||
cellClassName: 'datagrid--cell',
|
||||
|
||||
|
||||
editable: hasUpdatePermission,
|
||||
|
||||
|
||||
},
|
||||
|
||||
{
|
||||
field: 'content_html',
|
||||
headerName: 'ContentHTML',
|
||||
flex: 1,
|
||||
minWidth: 120,
|
||||
filterable: false,
|
||||
headerClassName: 'datagrid--header',
|
||||
cellClassName: 'datagrid--cell',
|
||||
|
||||
|
||||
editable: hasUpdatePermission,
|
||||
|
||||
|
||||
},
|
||||
|
||||
{
|
||||
field: 'content_markdown',
|
||||
headerName: 'ContentMarkdown',
|
||||
flex: 1,
|
||||
minWidth: 120,
|
||||
filterable: false,
|
||||
headerClassName: 'datagrid--header',
|
||||
cellClassName: 'datagrid--cell',
|
||||
|
||||
|
||||
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',
|
||||
headerName: 'SourceURL',
|
||||
flex: 1,
|
||||
minWidth: 120,
|
||||
filterable: false,
|
||||
headerClassName: 'datagrid--header',
|
||||
cellClassName: 'datagrid--cell',
|
||||
|
||||
|
||||
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',
|
||||
type: 'actions',
|
||||
minWidth: 30,
|
||||
headerClassName: 'datagrid--header',
|
||||
cellClassName: 'datagrid--cell',
|
||||
getActions: (params: GridRowParams) => {
|
||||
|
||||
return [
|
||||
<div key={params?.row?.id}>
|
||||
<ListActionsPopover
|
||||
onDelete={onDelete}
|
||||
itemId={params?.row?.id}
|
||||
pathEdit={`/articles/articles-edit/?id=${params?.row?.id}`}
|
||||
pathView={`/articles/articles-view/?id=${params?.row?.id}`}
|
||||
|
||||
hasUpdatePermission={hasUpdatePermission}
|
||||
|
||||
/>
|
||||
</div>,
|
||||
]
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import React, {useEffect, useRef} 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'
|
||||
|
||||
@ -1,389 +0,0 @@
|
||||
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,4 +1,5 @@
|
||||
import React, { ReactNode, useEffect, useState } from 'react'
|
||||
import React, { ReactNode, useEffect } from 'react'
|
||||
import { useState } from 'react'
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||
import menuAside from '../menuAside'
|
||||
|
||||
@ -8,12 +8,6 @@ const menuAside: MenuAsideItem[] = [
|
||||
label: 'Dashboard',
|
||||
},
|
||||
|
||||
{
|
||||
href: '/export-studio',
|
||||
icon: icon.mdiFileSwapOutline,
|
||||
label: 'Export Studio',
|
||||
},
|
||||
|
||||
{
|
||||
href: '/users/users-list',
|
||||
label: 'Users',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -34,13 +34,13 @@ const ArticlesTablesPage = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
|
||||
const [filters] = useState([
|
||||
{ label: 'Title', title: 'title' },
|
||||
{ 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 [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'},
|
||||
|
||||
|
||||
{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']},
|
||||
]);
|
||||
|
||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_ARTICLES');
|
||||
@ -94,7 +94,7 @@ const ArticlesTablesPage = () => {
|
||||
</SectionTitleLineWithButton>
|
||||
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
||||
|
||||
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/articles/articles-new'} color='info' label='New Article'/>}
|
||||
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/articles/articles-new'} color='info' label='New Item'/>}
|
||||
|
||||
<BaseButton
|
||||
className={'mr-3'}
|
||||
|
||||
@ -1,128 +1,691 @@
|
||||
import { mdiChartTimelineVariant } from '@mdi/js'
|
||||
import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js'
|
||||
import Head from 'next/head'
|
||||
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 React, { ReactElement } from 'react'
|
||||
import CardBox from '../../components/CardBox'
|
||||
import FormField from '../../components/FormField'
|
||||
import { RichTextField } from '../../components/RichTextField'
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||
import SectionMain from '../../components/SectionMain'
|
||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||
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 { useAppDispatch } from '../../stores/hooks'
|
||||
import { useRouter } from 'next/router'
|
||||
import moment from 'moment';
|
||||
|
||||
const initialValues = {
|
||||
title: '',
|
||||
status: 'draft',
|
||||
excerpt: '',
|
||||
content_html: '',
|
||||
content_markdown: '',
|
||||
source_url: '',
|
||||
|
||||
|
||||
title: '',
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
slug: '',
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
status: 'draft',
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
excerpt: '',
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
content_html: '',
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
content_markdown: '',
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
featured_images: [],
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
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 router = useRouter()
|
||||
const dispatch = useAppDispatch()
|
||||
const [submitError, setSubmitError] = useState('')
|
||||
|
||||
const handleSubmit = async (data, { setSubmitting }) => {
|
||||
setSubmitError('')
|
||||
|
||||
|
||||
|
||||
try {
|
||||
await dispatch(create(data)).unwrap()
|
||||
await router.push('/articles/articles-list')
|
||||
} catch (error) {
|
||||
console.error('Article create failed:', error)
|
||||
setSubmitError(getErrorMessage(error))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
const handleSubmit = async (data) => {
|
||||
await dispatch(create(data))
|
||||
await router.push('/articles/articles-list')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('New Article')}</title>
|
||||
<title>{getPageTitle('New Item')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title='New Article' main>
|
||||
{''}
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
<CardBox>
|
||||
<Formik initialValues={initialValues} onSubmit={handleSubmit}>
|
||||
{({ isSubmitting }) => (
|
||||
<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>
|
||||
<Formik
|
||||
initialValues={
|
||||
|
||||
initialValues
|
||||
|
||||
}
|
||||
onSubmit={(values) => handleSubmit(values)}
|
||||
>
|
||||
<Form>
|
||||
|
||||
{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'>
|
||||
<Field name='title' placeholder='Article title' />
|
||||
</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>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="Title"
|
||||
>
|
||||
<Field
|
||||
name="title"
|
||||
placeholder="Title"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label='Excerpt' hasTextareaHeight>
|
||||
<Field name='excerpt' as='textarea' placeholder='Optional short summary for the article list/export screen' />
|
||||
</FormField>
|
||||
|
||||
<FormField label='HTML Content' hasTextareaHeight>
|
||||
<Field name='content_html' id='content_html' component={RichTextField} />
|
||||
</FormField>
|
||||
|
||||
<FormField label='Meta Paste' hasTextareaHeight>
|
||||
<Field
|
||||
name='content_markdown'
|
||||
as='textarea'
|
||||
placeholder='Paste your metadata, notes, markdown, or source details here'
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label='Source URL'>
|
||||
<Field name='source_url' placeholder='Optional source URL' />
|
||||
</FormField>
|
||||
|
||||
<BaseDivider />
|
||||
<BaseButtons>
|
||||
<BaseButton type='submit' color='info' label={isSubmitting ? 'Saving...' : 'Save Article'} disabled={isSubmitting} />
|
||||
<BaseButton type='reset' color='info' outline label='Reset' disabled={isSubmitting} />
|
||||
<BaseButton
|
||||
type='button'
|
||||
color='danger'
|
||||
outline
|
||||
label='Cancel'
|
||||
onClick={() => router.push('/articles/articles-list')}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</BaseButtons>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<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>
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField label="Excerpt" hasTextareaHeight>
|
||||
<Field name="excerpt" as="textarea" placeholder="Excerpt" />
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField label='ContentHTML' hasTextareaHeight>
|
||||
<Field
|
||||
name='content_html'
|
||||
id='content_html'
|
||||
component={RichTextField}
|
||||
></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
|
||||
label="PublishedAt"
|
||||
>
|
||||
<Field
|
||||
type="datetime-local"
|
||||
name="published_at"
|
||||
placeholder="PublishedAt"
|
||||
/>
|
||||
</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 />
|
||||
<BaseButtons>
|
||||
<BaseButton type="submit" color="info" label="Submit" />
|
||||
<BaseButton type="reset" color="info" outline label="Reset" />
|
||||
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/articles/articles-list')}/>
|
||||
</BaseButtons>
|
||||
</Form>
|
||||
</Formik>
|
||||
</CardBox>
|
||||
</SectionMain>
|
||||
@ -131,7 +694,15 @@ const ArticlesNew = () => {
|
||||
}
|
||||
|
||||
ArticlesNew.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated permission='CREATE_ARTICLES'>{page}</LayoutAuthenticated>
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
|
||||
permission={'CREATE_ARTICLES'}
|
||||
|
||||
>
|
||||
{page}
|
||||
</LayoutAuthenticated>
|
||||
)
|
||||
}
|
||||
|
||||
export default ArticlesNew
|
||||
|
||||
@ -34,13 +34,13 @@ const ArticlesTablesPage = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
|
||||
const [filters] = useState([
|
||||
{ label: 'Title', title: 'title' },
|
||||
{ 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 [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'},
|
||||
|
||||
|
||||
{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']},
|
||||
]);
|
||||
|
||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_ARTICLES');
|
||||
@ -94,7 +94,7 @@ const ArticlesTablesPage = () => {
|
||||
</SectionTitleLineWithButton>
|
||||
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
||||
|
||||
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/articles/articles-new'} color='info' label='New Article'/>}
|
||||
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/articles/articles-new'} color='info' label='New Item'/>}
|
||||
|
||||
<BaseButton
|
||||
className={'mr-3'}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,159 +1,161 @@
|
||||
import React from 'react';
|
||||
|
||||
import React, { useEffect, useState } 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 BaseIcon from '../components/BaseIcon';
|
||||
import CardBox from '../components/CardBox';
|
||||
import SectionFullScreen from '../components/SectionFullScreen';
|
||||
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 className="min-h-screen bg-[#F7F3EA] text-slate-950">
|
||||
<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>
|
||||
<title>{getPageTitle('Affiliate Article Licensing')}</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="License affiliate article bundles, let buyers replace links, and export WordPress-ready content."
|
||||
/>
|
||||
<title>{getPageTitle('Starter Page')}</title>
|
||||
</Head>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</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">
|
||||
</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>
|
||||
<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>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -161,3 +163,4 @@ export default function Starter() {
|
||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user