import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { LuLayoutTemplate, LuPlay, LuPlus } from 'react-icons/lu'; import EmptyState from '../components/agent-analytics/EmptyState'; import ErrorBanner from '../components/agent-analytics/ErrorBanner'; import ConfirmDialog from '../components/ui/ConfirmDialog'; import { Spinner } from '../components/ui/Spinner'; import { contentTypeLabel, contentTypeTone, PAGE_CONTENT_TYPES, toneToBadgeClass, } from '../components/visibility/helpers'; import { presetToTemplate } from '../components/visibility/page-template-presets'; import PageTemplateDetailModal from '../components/visibility/PageTemplateDetailModal'; import PageTemplateEditorModal from '../components/visibility/PageTemplateEditorModal'; import RunPageTemplateModal from '../components/visibility/RunPageTemplateModal'; import { useAppStateContext } from '../context/user.data.context'; import { recomaze_ai_personalization_env } from '../env'; import type { PageContentType, PageTemplate, PageTemplatePreset, } from '../service/visibility/visibility.interface'; import { deletePageTemplate, listBrands, listPageTemplatePresets, listPageTemplates, } from '../service/visibility/visibility.service'; /** Filter value: a specific page type or "all". */ type TypeFilter = PageContentType | 'all'; /** Trim a directive into a short card preview. */ const previewText = (value: string): string => { const clean = value.trim(); if (clean.length <= 140) return clean; return `${clean.slice(0, 137)}…`; }; /** Normalized gallery card - a Recomaze preset or a merchant-owned template. */ interface GalleryCard { key: string; name: string; description: string; category: string; creator: string; isPreset: boolean; contentType: PageContentType; /** Template-shaped payload for the Run / Edit modals (id "" for a preset). */ template: PageTemplate; } /** * Page Templates gallery. Lists the Recomaze presets plus the brand's saved * page-content briefs behind a type filter, with "+ Create new", per-card * Run / Edit / Delete. Create, edit and run happen in modals; the gallery owns * the list state and merges saves in place. A Run lands on the Contents page. * * @returns {JSX.Element} The gallery page. */ const AiVisibilityPageTemplates = (): JSX.Element => { const { token, clientId } = useAppStateContext(); const hasClientCreds = Boolean(token && clientId); const navigate = useNavigate(); const [brandId, setBrandId] = useState(''); const [language, setLanguage] = useState('en'); const [brandName, setBrandName] = useState('You'); const [rows, setRows] = useState([]); const [presets, setPresets] = useState([]); const [filter, setFilter] = useState('all'); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [selected, setSelected] = useState(null); const [editorOpen, setEditorOpen] = useState(false); const [editing, setEditing] = useState(null); const [runOpen, setRunOpen] = useState(false); const [running, setRunning] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); const [deleting, setDeleting] = useState(false); const hasBrand = brandId.length > 0; const loadAll = useCallback(async (): Promise => { if (!hasClientCreds) return; setLoading(true); setError(null); // Resolve the brand first, on its own, so a page-templates fetch that // fails (e.g. the backend route isn't reachable yet) still renders the // gallery for an existing brand instead of falsely claiming no brand is // set up. let resolvedBrandId = ''; try { const brandList = await listBrands(clientId as string, token as string); const brand = brandList.brands[0] ?? null; if (brand) { resolvedBrandId = brand.brand_id; setBrandId(brand.brand_id); setLanguage(brand.language || 'en'); setBrandName(brand.brand_name || 'You'); } else { setBrandId(''); } } catch { /* no brand resolvable - fall through to the "set up a brand" state */ } if (resolvedBrandId) { try { setRows( await listPageTemplates( clientId as string, token as string, resolvedBrandId ) ); } catch { /* brand exists but templates couldn't load - show an empty gallery */ } try { setPresets( await listPageTemplatePresets(clientId as string, token as string) ); } catch { /* presets couldn't load - the gallery shows saved templates only */ } } setLoading(false); }, [hasClientCreds, clientId, token]); useEffect(() => { void loadAll(); }, [loadAll]); // Merchant templates first (creator = brand), then Recomaze presets, so the // merchant's own briefs lead but there is always something to run. const cards = useMemo(() => { const userCards: GalleryCard[] = rows.map(row => ({ key: row.template_id, name: row.name, description: row.description, category: contentTypeLabel(row.content_type), creator: brandName, isPreset: false, contentType: row.content_type, template: row, })); const presetCards: GalleryCard[] = presets.map(preset => ({ key: preset.preset_id, name: preset.name, description: preset.description, category: preset.category, creator: preset.created_by, isPreset: true, contentType: preset.content_type, template: presetToTemplate(preset, brandId, language), })); return [...userCards, ...presetCards]; }, [rows, presets, brandName, brandId, language]); const visible = useMemo( () => filter === 'all' ? cards : cards.filter(card => card.contentType === filter), [cards, filter] ); const openCreate = (): void => { setEditing(null); setEditorOpen(true); }; const handleSaved = (saved: PageTemplate): void => { setRows(prev => { const index = prev.findIndex( row => row.template_id === saved.template_id ); if (index === -1) return [saved, ...prev]; const next = [...prev]; next[index] = saved; return next; }); }; const handleConfirmDelete = async (): Promise => { if (!deleteTarget) return; setDeleting(true); setError(null); try { await deletePageTemplate( clientId as string, token as string, brandId, deleteTarget.template_id ); setRows(prev => prev.filter(row => row.template_id !== deleteTarget.template_id) ); setDeleteTarget(null); } catch (err) { setError( err instanceof Error ? err.message : 'Could not delete the template.' ); } finally { setDeleting(false); } }; if (!hasClientCreds) { return (
); } return (

Page Templates

Reusable briefs you replay to generate blog, service, or content pages, plus copy-paste platform profiles. Tweak the directive each time you run one.

{hasBrand && ( )}
{error && } {loading ? (
) : !hasBrand ? ( ) : ( <>
{(['all', ...PAGE_CONTENT_TYPES] as TypeFilter[]).map(value => ( ))}
{visible.map(card => (
setSelected(card)} onKeyDown={event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); setSelected(card); } }} className="flex cursor-pointer flex-col rounded-xl border border-gray-200 bg-white transition-colors hover:border-pink-300" >

{card.name}

{contentTypeLabel(card.contentType)}
{card.description ? (

{card.description}

) : null} {card.template.default_instructions ? (

{previewText(card.template.default_instructions)}

) : null}
{card.isPreset ? ( Recomaze ) : null} {card.creator}
))}
)}
{hasBrand && ( <> setSelected(null)} name={selected?.name ?? ''} description={selected?.description ?? ''} creator={selected?.creator ?? ''} creatorIsRecomaze={selected ? selected.isPreset : false} category={selected?.category ?? ''} contentType={selected?.contentType ?? 'blog'} canDelete={selected ? !selected.isPreset : false} onRun={() => { if (!selected) return; setRunning(selected.template); setSelected(null); setRunOpen(true); }} onModify={() => { if (!selected) return; setEditing(selected.template); setSelected(null); setEditorOpen(true); }} onDelete={() => { if (!selected) return; setDeleteTarget(selected.template); setSelected(null); }} /> setEditorOpen(false)} clientId={clientId as string} token={token as string} brandId={brandId} language={language} template={editing} onSaved={handleSaved} /> setRunOpen(false)} clientId={clientId as string} token={token as string} brandId={brandId} language={language} template={running} onStarted={() => navigate('/ai-visibility/contents')} /> { if (!next) setDeleteTarget(null); }} title="Delete this template?" description={`Delete "${deleteTarget?.name ?? ''}"? This permanently removes the brief. This cannot be undone.`} confirmLabel="Delete" tone="destructive" busy={deleting} busyLabel="Deleting…" onConfirm={handleConfirmDelete} /> )}
); }; export default AiVisibilityPageTemplates;