280 lines
11 KiB
TypeScript
280 lines
11 KiB
TypeScript
import { useEffect, useState, useMemo } from 'react'
|
|
import axios from 'axios'
|
|
import { Search, Edit2, X, Check, Filter, Download } from 'lucide-react'
|
|
import clsx from 'clsx'
|
|
|
|
interface MarketingMatrixManagerProps {
|
|
apiBase: string
|
|
}
|
|
|
|
interface MatrixEntry {
|
|
id: number
|
|
industry_id: number
|
|
persona_id: number
|
|
industry_name: string
|
|
persona_name: string
|
|
subject: string | null
|
|
intro: string | null
|
|
social_proof: string | null
|
|
updated_at: string
|
|
}
|
|
|
|
interface Industry {
|
|
id: number
|
|
name: string
|
|
}
|
|
|
|
interface Persona {
|
|
id: number
|
|
name: string
|
|
}
|
|
|
|
export function MarketingMatrixManager({ apiBase }: MarketingMatrixManagerProps) {
|
|
const [entries, setEntries] = useState<MatrixEntry[]>([])
|
|
const [industries, setIndustries] = useState<Industry[]>([])
|
|
const [personas, setPersonas] = useState<Persona[]>([])
|
|
const [isLoading, setIsLoading] = useState(false)
|
|
|
|
// Filters
|
|
const [industryFilter, setIndustryFilter] = useState<number | 'all'>('all')
|
|
const [personaFilter, setPersonaFilter] = useState<number | 'all'>('all')
|
|
const [searchTerm, setSearchTerm] = useState('')
|
|
|
|
// Editing state
|
|
const [editingId, setEditingId] = useState<number | null>(null)
|
|
const [editValues, setEditValues] = useState<{
|
|
subject: string
|
|
intro: string
|
|
social_proof: string
|
|
}>({ subject: '', intro: '', social_proof: '' })
|
|
|
|
const fetchMetadata = async () => {
|
|
try {
|
|
const [resInd, resPers] = await Promise.all([
|
|
axios.get(`${apiBase}/industries`),
|
|
axios.get(`${apiBase}/matrix/personas`)
|
|
])
|
|
setIndustries(resInd.data)
|
|
setPersonas(resPers.data)
|
|
} catch (e) {
|
|
console.error("Failed to fetch metadata:", e)
|
|
}
|
|
}
|
|
|
|
const fetchEntries = async () => {
|
|
setIsLoading(true)
|
|
try {
|
|
const params: any = {}
|
|
if (industryFilter !== 'all') params.industry_id = industryFilter
|
|
if (personaFilter !== 'all') params.persona_id = personaFilter
|
|
|
|
const res = await axios.get(`${apiBase}/matrix`, { params })
|
|
setEntries(res.data)
|
|
} catch (e) {
|
|
console.error("Failed to fetch matrix entries:", e)
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
fetchMetadata()
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
fetchEntries()
|
|
}, [industryFilter, personaFilter])
|
|
|
|
const filteredEntries = useMemo(() => {
|
|
if (!searchTerm) return entries
|
|
const s = searchTerm.toLowerCase()
|
|
return entries.filter(e =>
|
|
e.industry_name.toLowerCase().includes(s) ||
|
|
e.persona_name.toLowerCase().includes(s) ||
|
|
(e.subject?.toLowerCase().includes(s)) ||
|
|
(e.intro?.toLowerCase().includes(s)) ||
|
|
(e.social_proof?.toLowerCase().includes(s))
|
|
)
|
|
}, [entries, searchTerm])
|
|
|
|
const startEditing = (entry: MatrixEntry) => {
|
|
setEditingId(entry.id)
|
|
setEditValues({
|
|
subject: entry.subject || '',
|
|
intro: entry.intro || '',
|
|
social_proof: entry.social_proof || ''
|
|
})
|
|
}
|
|
|
|
const cancelEditing = () => {
|
|
setEditingId(null)
|
|
}
|
|
|
|
const saveEditing = async (id: number) => {
|
|
try {
|
|
await axios.put(`${apiBase}/matrix/${id}`, editValues)
|
|
setEntries(prev => prev.map(e => e.id === id ? { ...e, ...editValues } : e))
|
|
setEditingId(null)
|
|
} catch (e) {
|
|
alert("Save failed")
|
|
console.error(e)
|
|
}
|
|
}
|
|
|
|
const handleDownloadCSV = () => {
|
|
let url = `${apiBase}/matrix/export`
|
|
const params = new URLSearchParams()
|
|
if (industryFilter !== 'all') params.append('industry_id', industryFilter.toString())
|
|
if (personaFilter !== 'all') params.append('persona_id', personaFilter.toString())
|
|
|
|
if (params.toString()) {
|
|
url += `?${params.toString()}`
|
|
}
|
|
|
|
window.open(url, '_blank')
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Toolbar */}
|
|
<div className="flex flex-wrap items-center gap-3 bg-slate-50 dark:bg-slate-950 p-3 rounded-lg border border-slate-200 dark:border-slate-800">
|
|
<div className="flex items-center gap-2">
|
|
<Filter className="h-4 w-4 text-slate-400" />
|
|
<span className="text-xs font-bold text-slate-500 uppercase">Filters:</span>
|
|
</div>
|
|
|
|
<select
|
|
className="bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-700 rounded px-2 py-1.5 text-xs outline-none focus:ring-1 focus:ring-blue-500"
|
|
value={industryFilter}
|
|
onChange={e => setIndustryFilter(e.target.value === 'all' ? 'all' : parseInt(e.target.value))}
|
|
>
|
|
<option value="all">All Industries</option>
|
|
{industries.map(i => <option key={i.id} value={i.id}>{i.name}</option>)}
|
|
</select>
|
|
|
|
<select
|
|
className="bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-700 rounded px-2 py-1.5 text-xs outline-none focus:ring-1 focus:ring-blue-500"
|
|
value={personaFilter}
|
|
onChange={e => setPersonaFilter(e.target.value === 'all' ? 'all' : parseInt(e.target.value))}
|
|
>
|
|
<option value="all">All Personas</option>
|
|
{personas.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
|
</select>
|
|
|
|
<div className="flex-1 min-w-[200px] relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-slate-400" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search in texts..."
|
|
className="w-full bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-700 rounded px-9 py-1.5 text-xs outline-none focus:ring-1 focus:ring-blue-500"
|
|
value={searchTerm}
|
|
onChange={e => setSearchTerm(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<button
|
|
onClick={handleDownloadCSV}
|
|
className="flex items-center gap-2 bg-slate-200 dark:bg-slate-800 hover:bg-slate-300 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 px-3 py-1.5 rounded text-xs font-bold transition-all border border-slate-300 dark:border-slate-700"
|
|
>
|
|
<Download className="h-3.5 w-3.5" />
|
|
EXPORT CSV
|
|
</button>
|
|
</div>
|
|
|
|
{/* Table */}
|
|
<div className="border border-slate-200 dark:border-slate-800 rounded-lg overflow-hidden bg-white dark:bg-slate-950">
|
|
<table className="w-full text-left text-xs table-fixed">
|
|
<thead className="bg-slate-50 dark:bg-slate-900 border-b border-slate-200 dark:border-slate-800 text-slate-500 font-bold uppercase">
|
|
<tr>
|
|
<th className="p-3 w-40">Combination</th>
|
|
<th className="p-3 w-1/4">Subject Line</th>
|
|
<th className="p-3 w-1/3">Intro Text</th>
|
|
<th className="p-3">Social Proof</th>
|
|
<th className="p-3 w-20 text-center">Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-100 dark:divide-slate-800/50">
|
|
{isLoading ? (
|
|
<tr><td colSpan={5} className="p-12 text-center text-slate-400 italic">Loading matrix entries...</td></tr>
|
|
) : filteredEntries.length === 0 ? (
|
|
<tr><td colSpan={5} className="p-12 text-center text-slate-400 italic">No entries found for the selected filters.</td></tr>
|
|
) : filteredEntries.map(entry => (
|
|
<tr key={entry.id} className={clsx("group transition-colors", editingId === entry.id ? "bg-blue-50/50 dark:bg-blue-900/10" : "hover:bg-slate-50/50 dark:hover:bg-slate-900/30")}>
|
|
<td className="p-3 align-top">
|
|
<div className="font-bold text-slate-900 dark:text-white leading-tight mb-1">{entry.industry_name}</div>
|
|
<div className="text-[10px] text-blue-600 dark:text-blue-400 font-bold uppercase tracking-wider">{entry.persona_name}</div>
|
|
</td>
|
|
|
|
<td className="p-3 align-top">
|
|
{editingId === entry.id ? (
|
|
<input
|
|
className="w-full bg-white dark:bg-slate-900 border border-blue-300 dark:border-blue-700 rounded p-1.5 outline-none"
|
|
value={editValues.subject}
|
|
onChange={e => setEditValues(v => ({ ...v, subject: e.target.value }))}
|
|
/>
|
|
) : (
|
|
<div className="text-slate-700 dark:text-slate-300">{entry.subject || <span className="text-slate-400 italic">Empty</span>}</div>
|
|
)}
|
|
</td>
|
|
|
|
<td className="p-3 align-top">
|
|
{editingId === entry.id ? (
|
|
<textarea
|
|
className="w-full bg-white dark:bg-slate-900 border border-blue-300 dark:border-blue-700 rounded p-1.5 outline-none h-24 text-[11px]"
|
|
value={editValues.intro}
|
|
onChange={e => setEditValues(v => ({ ...v, intro: e.target.value }))}
|
|
/>
|
|
) : (
|
|
<div className="text-slate-600 dark:text-slate-400 line-clamp-4 hover:line-clamp-none transition-all">{entry.intro || <span className="text-slate-400 italic">Empty</span>}</div>
|
|
)}
|
|
</td>
|
|
|
|
<td className="p-3 align-top">
|
|
{editingId === entry.id ? (
|
|
<textarea
|
|
className="w-full bg-white dark:bg-slate-900 border border-blue-300 dark:border-blue-700 rounded p-1.5 outline-none h-24 text-[11px]"
|
|
value={editValues.social_proof}
|
|
onChange={e => setEditValues(v => ({ ...v, social_proof: e.target.value }))}
|
|
/>
|
|
) : (
|
|
<div className="text-slate-600 dark:text-slate-400 line-clamp-4 hover:line-clamp-none transition-all">{entry.social_proof || <span className="text-slate-400 italic">Empty</span>}</div>
|
|
)}
|
|
</td>
|
|
|
|
<td className="p-3 align-top text-center">
|
|
{editingId === entry.id ? (
|
|
<div className="flex flex-col gap-2">
|
|
<button
|
|
onClick={() => saveEditing(entry.id)}
|
|
className="p-1.5 bg-green-600 text-white rounded hover:bg-green-500 transition-colors shadow-sm"
|
|
title="Save Changes"
|
|
>
|
|
<Check className="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
onClick={cancelEditing}
|
|
className="p-1.5 bg-slate-200 dark:bg-slate-800 text-slate-600 dark:text-slate-400 rounded hover:bg-slate-300 dark:hover:bg-slate-700 transition-colors"
|
|
title="Cancel"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<button
|
|
onClick={() => startEditing(entry)}
|
|
className="p-2 text-slate-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-full transition-all opacity-0 group-hover:opacity-100"
|
|
title="Edit Entry"
|
|
>
|
|
<Edit2 className="h-4 w-4" />
|
|
</button>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|