// The standing content disclaimer, edited where the content lives: a merchant
// looks for it next to the contents it rides along with, not in a settings
// drawer three clicks away.
import React, { useEffect, useState } from 'react';
import { LuScrollText } from 'react-icons/lu';
import { cn } from '../copilot/cn';
import { ARTICLE_STATUS } from '../../service/visibility/visibility.constants';
import type {
ArticleSummary,
BrandResponse,
DisclaimerPosition,
} from '../../service/visibility/visibility.interface';
import {
getArticle,
patchBrand,
} from '../../service/visibility/visibility.service';
import Modal from './Modal';
import PagePreview from './PagePreview';
// Marker the generator wraps the disclaimer in, mirrored on the backend.
const DISCLAIMER_CLASS = 'recomaze-disclaimer';
function withDisclaimer(
markdown: string,
text: string,
position: DisclaimerPosition
): string {
const line = text.trim();
if (!line) return markdown;
const span = `${line}`;
return position === 'top'
? `${span}\n\n${markdown}`
: `${markdown}\n\n${span}`;
}
interface DisclaimerDialogProps {
clientId: string;
token: string;
brand: BrandResponse | null;
articles?: ArticleSummary[];
onSaved?: (brand: BrandResponse) => void;
onToast?: (message: string) => void;
}
export function DisclaimerDialog({
clientId,
token,
brand,
articles = [],
onSaved,
onToast,
}: DisclaimerDialogProps): JSX.Element {
const [open, setOpen] = useState(false);
const [sample, setSample] = useState<{
title: string;
slug: string | null;
markdown: string;
} | null>(null);
const [text, setText] = useState('');
const [position, setPosition] = useState('bottom');
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
// Re-seed whenever the dialog opens, so a cancelled edit never lingers.
useEffect(() => {
if (!open) return;
setText(brand?.article_disclaimer || '');
setPosition(
brand?.article_disclaimer_position === 'top' ? 'top' : 'bottom'
);
}, [open, brand]);
// Preview against one of the merchant's own articles rather than grey
// placeholder bars: seeing the line land under their actual prose is the
// whole question they are trying to answer.
useEffect(() => {
if (!open || sample) return;
const ready = articles.filter(
article => article.status === ARTICLE_STATUS.READY
);
const pick = ready[Math.floor(Math.random() * ready.length)];
if (!pick) return;
let cancelled = false;
getArticle(clientId, token, pick.article_id)
.then(detail => {
if (cancelled) return;
setSample({
title: detail.h1 || detail.summary.title || pick.title,
slug: detail.summary.slug ?? null,
markdown: detail.markdown || '',
});
})
.catch(() => {
// Fall back to the skeleton preview below.
});
return () => {
cancelled = true;
};
}, [open, sample, articles, clientId, token]);
const save = async (): Promise => {
if (!brand) return;
setSaving(true);
try {
const updated = await patchBrand(clientId, token, brand.brand_id, {
article_disclaimer: text.trim(),
article_disclaimer_position: position,
});
onSaved?.(updated);
onToast?.(
text.trim()
? 'Disclaimer saved. It rides with every content generated from now on.'
: 'Disclaimer removed from future contents.'
);
setOpen(false);
} catch (saveError) {
setError(
saveError instanceof Error
? saveError.message
: 'Could not save the disclaimer.'
);
} finally {
setSaving(false);
}
};
const active = Boolean(brand?.article_disclaimer?.trim());
return (
<>
setOpen(false)}
title="Content Disclaimer"
size="lg"
footer={
<>
>
}
>
A standing line - prices change, not medical advice, an affiliate
notice - added to every content generated from now on. Contents
already written keep what they have.
Position
{(['bottom', 'top'] as DisclaimerPosition[]).map(option => (
))}
{error &&
{error}
}
{/* The real page, not an impression of it - so "where does this
land" is answered by looking at it. */}
{sample && (