This commit is contained in:
romantarkin 2025-07-23 16:19:07 +05:00
parent 6e89deb3f5
commit 63eb19fd02
3 changed files with 266 additions and 2 deletions

View File

@ -0,0 +1,47 @@
import React, { useState, useEffect } from 'react';
import Modal from './Modal';
import styles from '../styles/TemplateModal.module.css';
export default function CreateTemplateVersionModal({ isOpen, onClose, loading, onSave, initial }) {
const [subject, setSubject] = useState(initial?.subject || '');
const [bodyHtml, setBodyHtml] = useState(initial?.body_html || '');
const [bodyText, setBodyText] = useState(initial?.body_text || '');
useEffect(() => {
if (initial) {
setSubject(initial.subject || '');
setBodyHtml(initial.body_html || '');
setBodyText(initial.body_text || '');
} else {
setSubject('');
setBodyHtml('');
setBodyText('');
}
}, [initial, isOpen]);
const handleSubmit = (e) => {
e.preventDefault();
onSave({ subject, body_html: bodyHtml, body_text: bodyText });
};
return (
<Modal isOpen={isOpen} onClose={onClose} disabled={loading}>
<h3 className={styles.title}>{initial ? 'Редактировать версию' : 'Добавить версию шаблона'}</h3>
<form onSubmit={handleSubmit} className={styles.form}>
<label className={styles.label}>Тема письма
<input type="text" value={subject} onChange={e => setSubject(e.target.value)} required className={styles.input} />
</label>
<label className={styles.label}>HTML-версия
<textarea value={bodyHtml} onChange={e => setBodyHtml(e.target.value)} className={styles.input} rows={4} placeholder="&lt;h1&gt;Заголовок&lt;/h1&gt;..." />
</label>
<label className={styles.label}>Текстовая версия
<textarea value={bodyText} onChange={e => setBodyText(e.target.value)} className={styles.input} rows={4} placeholder="Текст письма..." />
</label>
<div className={styles.actions}>
<button type="submit" disabled={loading} className={styles.saveBtn}>{loading ? (initial ? 'Сохранение...' : 'Создание...') : (initial ? 'Сохранить' : 'Создать')}</button>
<button type="button" onClick={onClose} disabled={loading} className={styles.cancelBtn}>Отмена</button>
</div>
</form>
</Modal>
);
}

View File

@ -0,0 +1,207 @@
import React, { useState, useEffect, useCallback } from 'react';
import CreateTemplateVersionModal from '../modals/CreateTemplateVersionModal';
const PAGE_SIZE = 10;
export default function TemplateVersionsPage({ template, onBack, token }) {
const [versions, setVersions] = useState([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [createVersionModal, setCreateVersionModal] = useState(false);
const [createVersionLoading, setCreateVersionLoading] = useState(false);
const [editVersion, setEditVersion] = useState(null);
const [editVersionLoading, setEditVersionLoading] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(null);
const fetchVersions = useCallback(async (page) => {
setLoading(true);
setError('');
try {
const offset = (page - 1) * PAGE_SIZE;
const res = await fetch(`/api/mail/email-template-versions?template_id=${template.id}&limit=${PAGE_SIZE}&offset=${offset}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
const data = await res.json();
let rows = [];
let count = 0;
if (res.ok && Array.isArray(data)) {
rows = data.filter(v => v.template_id === template.id);
count = rows.length;
} else if (res.ok && Array.isArray(data.rows)) {
rows = data.rows.filter(v => v.template_id === template.id);
count = data.count || rows.length;
}
setVersions(rows);
setTotal(count);
} catch (e) {
setError('Ошибка сети');
setVersions([]);
setTotal(0);
} finally {
setLoading(false);
}
}, [template.id, token]);
useEffect(() => {
fetchVersions(page);
}, [fetchVersions, page]);
const handleAddVersion = () => setCreateVersionModal(true);
const handleCreateVersionSave = async (versionData) => {
setCreateVersionLoading(true);
try {
const res = await fetch('/api/mail/email-template-versions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
},
body: JSON.stringify({ ...versionData, template_id: template.id })
});
if (!res.ok) {
const data = await res.json();
alert(data.error || 'Ошибка создания версии');
} else {
setCreateVersionModal(false);
fetchVersions(page);
}
} catch (e) {
alert('Ошибка сети');
} finally {
setCreateVersionLoading(false);
}
};
// --- Edit version ---
const handleEditVersion = (version) => setEditVersion(version);
const handleEditVersionSave = async (data) => {
setEditVersionLoading(true);
try {
const res = await fetch(`/api/mail/email-template-versions/${editVersion.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
},
body: JSON.stringify({
subject: data.subject,
body_html: data.body_html,
body_text: data.body_text
})
});
if (!res.ok) {
const resp = await res.json();
alert(resp.error || 'Ошибка обновления версии');
} else {
setEditVersion(null);
fetchVersions(page);
}
} catch (e) {
alert('Ошибка сети');
} finally {
setEditVersionLoading(false);
}
};
// --- Delete version ---
const handleDeleteVersion = async (id) => {
if (!window.confirm('Удалить эту версию шаблона?')) return;
setDeleteLoading(id);
try {
const res = await fetch(`/api/mail/email-template-versions/${id}`, {
method: 'DELETE',
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
if (!res.ok) {
const data = await res.json();
alert(data.error || 'Ошибка удаления');
} else {
fetchVersions(page);
}
} catch (e) {
alert('Ошибка сети');
} finally {
setDeleteLoading(null);
}
};
return (
<div>
<button onClick={onBack} style={{ marginBottom: 18, background: 'none', border: 'none', color: '#6366f1', fontWeight: 600, fontSize: 16, cursor: 'pointer' }}> Назад к шаблонам</button>
<h2>Версии шаблона: {template.name}</h2>
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 12 }}>
<button onClick={handleAddVersion} style={addBtnStyle}>+ Добавить версию</button>
</div>
{loading && <div>Загрузка...</div>}
{error && <div style={{ color: 'red' }}>{error}</div>}
{!loading && !error && (
<>
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 16, background: '#fff', borderRadius: 8, overflow: 'hidden' }}>
<thead style={{ background: '#f3f4f6' }}>
<tr>
<th style={thStyle}>ID</th>
<th style={thStyle}>Версия</th>
<th style={thStyle}>Тема</th>
<th style={thStyle}>HTML</th>
<th style={thStyle}>Текст</th>
<th style={thStyle}></th>
</tr>
</thead>
<tbody>
{versions.map(v => (
<tr key={v.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
<td style={tdStyle}>{v.id}</td>
<td style={tdStyle}>{v.version_number}</td>
<td style={tdStyle}>{v.subject}</td>
<td style={tdStyle}>{v.body_html ? <span style={{ color: '#10b981' }}>Есть</span> : <span style={{ color: '#ef4444' }}>Нет</span>}</td>
<td style={tdStyle}>{v.body_text ? <span style={{ color: '#10b981' }}>Есть</span> : <span style={{ color: '#ef4444' }}>Нет</span>}</td>
<td style={tdStyle}>
<button onClick={() => handleEditVersion(v)} style={btnStyle}>Редактировать</button>
<button onClick={() => handleDeleteVersion(v.id)} style={btnStyle} disabled={deleteLoading === v.id}>
{deleteLoading === v.id ? 'Удаление...' : 'Удалить'}
</button>
</td>
</tr>
))}
{versions.length === 0 && (
<tr><td colSpan={6} style={{ textAlign: 'center', color: '#9ca3af', padding: 24 }}>Нет версий</td></tr>
)}
</tbody>
</table>
{/* Пагинация если нужно */}
{total > PAGE_SIZE && (
<div style={{ marginTop: 16 }}>
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1} style={btnStyle}>Назад</button>
<span style={{ margin: '0 12px' }}>Страница {page}</span>
<button onClick={() => setPage(p => p + 1)} disabled={page * PAGE_SIZE >= total} style={btnStyle}>Вперёд</button>
</div>
)}
</>
)}
{createVersionModal && (
<CreateTemplateVersionModal
isOpen={createVersionModal}
onClose={() => setCreateVersionModal(false)}
loading={createVersionLoading}
onSave={handleCreateVersionSave}
/>
)}
{editVersion && (
<CreateTemplateVersionModal
isOpen={!!editVersion}
onClose={() => setEditVersion(null)}
loading={editVersionLoading}
onSave={handleEditVersionSave}
initial={editVersion}
/>
)}
</div>
);
}
const thStyle = { padding: '10px 16px', textAlign: 'left', fontWeight: 600, borderBottom: '2px solid #e5e7eb', background: '#f3f4f6' };
const tdStyle = { padding: '10px 16px', background: '#fff' };
const btnStyle = { marginRight: 8, padding: '6px 12px', borderRadius: 6, border: 'none', background: '#6366f1', color: '#fff', cursor: 'pointer', fontWeight: 500 };
const addBtnStyle = { background: 'linear-gradient(90deg, #6366f1 0%, #06b6d4 100%)', color: '#fff', border: 'none', borderRadius: 8, padding: '10px 22px', fontSize: 16, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 8px 0 rgba(99,102,241,0.10)', transition: 'background 0.2s' };

View File

@ -25,9 +25,19 @@ app.use('/api/mail', routes);
}
})();
let isQueueFilling = false;
// Периодически заполняем очередь для scheduled кампаний
setInterval(() => {
processScheduledCampaigns().catch(err => console.error('Queue fill error:', err));
setInterval(async () => {
if (isQueueFilling) return;
isQueueFilling = true;
try {
await processScheduledCampaigns();
} catch (err) {
console.error('Queue fill error:', err);
} finally {
isQueueFilling = false;
}
}, 60 * 1000); // раз в минуту
const PORT = process.env.PORT || 3000;