unsubscribe
This commit is contained in:
parent
aeb0ffc0d2
commit
e36042e24d
@ -6,6 +6,7 @@ import UsersPage from './pages/UsersPage';
|
||||
import SmtpServersPage from './pages/SmtpServersPage';
|
||||
import EmailTemplatesPage from './pages/EmailTemplatesPage';
|
||||
import UnsubscribedPage from './pages/UnsubscribedPage';
|
||||
import UnsubscribePage from './pages/UnsubscribePage';
|
||||
import GroupsPage from './pages/GroupsPage';
|
||||
import DeliveryHistoryPage from './pages/DeliveryHistoryPage';
|
||||
import CampaignPage from './pages/CampaignPage';
|
||||
@ -27,6 +28,7 @@ function App() {
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/unsubscribe" element={<UnsubscribePage />} />
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
|
||||
@ -25,6 +25,7 @@ code {
|
||||
:root {
|
||||
--background-color: #ffffff;
|
||||
--text-color: #000000;
|
||||
--text-secondary: #6b7280;
|
||||
--border-color: #e5e7eb;
|
||||
--card-background: #ffffff;
|
||||
--hover-background: #f9fafb;
|
||||
@ -35,6 +36,7 @@ code {
|
||||
[data-theme="dark"] {
|
||||
--background-color: #1a1a1a;
|
||||
--text-color: #ffffff;
|
||||
--text-secondary: #a0a0a0;
|
||||
--border-color: #333333;
|
||||
--card-background: #2d2d2d;
|
||||
--hover-background: #3a3a3a;
|
||||
|
||||
124
frontend/src/pages/UnsubscribePage.js
Normal file
124
frontend/src/pages/UnsubscribePage.js
Normal file
@ -0,0 +1,124 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import styles from './UnsubscribePage.module.css';
|
||||
import ThemeToggle from '../components/ThemeToggle';
|
||||
|
||||
const UnsubscribePage = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [email, setEmail] = useState(searchParams.get('email') || '');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!email.trim()) {
|
||||
setError('Пожалуйста, введите email адрес');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setSubmitted(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/subscribers/unsubscribe', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email: email.trim() }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setSuccess(true);
|
||||
} else {
|
||||
setError(data.error || 'Произошла ошибка при отписке');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Ошибка сети. Пожалуйста, попробуйте позже.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmailChange = (e) => {
|
||||
setEmail(e.target.value);
|
||||
if (submitted) {
|
||||
setError('');
|
||||
setSuccess(false);
|
||||
setSubmitted(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.themeToggleContainer}>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<div className={styles.form}>
|
||||
<div className={styles.successIcon}>✓</div>
|
||||
<h2 className={styles.title}>Отписка выполнена</h2>
|
||||
<p className={styles.message}>
|
||||
Вы успешно отписались от рассылки. Мы больше не будем отправлять вам письма.
|
||||
</p>
|
||||
<p className={styles.emailInfo}>
|
||||
Email: <strong>{email}</strong>
|
||||
</p>
|
||||
<p className={styles.note}>
|
||||
Если вы передумали, вы всегда можете подписаться снова, обратившись к нам.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.themeToggleContainer}>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className={styles.form}>
|
||||
<h2 className={styles.title}>Отписаться от рассылки</h2>
|
||||
<p className={styles.description}>
|
||||
Введите ваш email адрес, чтобы отписаться от получения наших писем
|
||||
</p>
|
||||
|
||||
<div className={styles.inputGroup}>
|
||||
<label htmlFor="email" className={styles.label}>
|
||||
Email адрес
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
required
|
||||
className={styles.input}
|
||||
disabled={loading}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
|
||||
<button type="submit" className={styles.button} disabled={loading}>
|
||||
{loading ? 'Отписываемся...' : 'Отписаться'}
|
||||
</button>
|
||||
|
||||
<div className={styles.info}>
|
||||
<p>• Отписка вступает в силу немедленно</p>
|
||||
<p>• Вы больше не будете получать наши письма</p>
|
||||
<p>• Ваши данные будут сохранены для предотвращения повторной рассылки</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnsubscribePage;
|
||||
295
frontend/src/pages/UnsubscribePage.module.css
Normal file
295
frontend/src/pages/UnsubscribePage.module.css
Normal file
@ -0,0 +1,295 @@
|
||||
.wrapper {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: linear-gradient(135deg, #e0e7ff 0%, #f0fdfa 100%);
|
||||
padding: 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .wrapper {
|
||||
background: linear-gradient(135deg, #1e1b4b 0%, #0f172a 100%);
|
||||
}
|
||||
|
||||
.themeToggleContainer {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 400px;
|
||||
max-width: 100%;
|
||||
gap: 24px;
|
||||
padding: 40px;
|
||||
border-radius: 16px;
|
||||
background: var(--card-background);
|
||||
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.15);
|
||||
border: 1px solid var(--border-color);
|
||||
transition: box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
font-size: 28px;
|
||||
color: #6366f1;
|
||||
text-align: center;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.inputGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.input {
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
font-size: 16px;
|
||||
outline: none;
|
||||
transition: border 0.2s;
|
||||
background: var(--card-background);
|
||||
color: var(--text-color);
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border: 1.5px solid #6366f1;
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 12px 0;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: linear-gradient(90deg, #ef4444 0%, #dc2626 100%);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px 0 rgba(239, 68, 68, 0.2);
|
||||
transition: all 0.2s;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background: linear-gradient(90deg, #dc2626 0%, #b91c1c 100%);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px 0 rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ef4444;
|
||||
text-align: center;
|
||||
font-size: 15px;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.info {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .info {
|
||||
background: #1e293b;
|
||||
border-color: #334155;
|
||||
}
|
||||
|
||||
.info p {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.info p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Success state styles */
|
||||
.successIcon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(90deg, #10b981 0%, #059669 100%);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
margin: 0 auto 16px;
|
||||
box-shadow: 0 4px 12px 0 rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.message {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
color: var(--text-color);
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.emailInfo {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
padding: 12px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .emailInfo {
|
||||
background: #334155;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
font-style: italic;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Адаптивные стили для планшетов */
|
||||
@media (max-width: 1024px) {
|
||||
.form {
|
||||
width: 380px;
|
||||
padding: 36px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.input {
|
||||
font-size: 15px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.button {
|
||||
font-size: 17px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Адаптивные стили для мобильных устройств */
|
||||
@media (max-width: 768px) {
|
||||
.wrapper {
|
||||
padding: 12px;
|
||||
align-items: flex-start;
|
||||
padding-top: 60px;
|
||||
}
|
||||
|
||||
.themeToggleContainer {
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
.form {
|
||||
width: 100%;
|
||||
max-width: 450px;
|
||||
padding: 32px 24px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.input {
|
||||
font-size: 16px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.button {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.successIcon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
font-size: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.wrapper {
|
||||
padding: 8px;
|
||||
padding-top: 40px;
|
||||
}
|
||||
|
||||
.themeToggleContainer {
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
.form {
|
||||
padding: 28px 20px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.input {
|
||||
font-size: 15px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.button {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.successIcon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Улучшенная поддержка touch устройств */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.input {
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
.button {
|
||||
min-height: 52px;
|
||||
}
|
||||
}
|
||||
@ -81,4 +81,67 @@ export default {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
},
|
||||
async unsubscribe(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const subscriber = await Subscriber.findByPk(id);
|
||||
|
||||
if (!subscriber) {
|
||||
return res.status(404).json({ error: 'Subscriber not found' });
|
||||
}
|
||||
|
||||
// Обновляем статус на "unsubscribed" и устанавливаем время отписки
|
||||
await subscriber.update({
|
||||
status: 'unsubscribed',
|
||||
unsubscribed_at: new Date()
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: 'Subscriber successfully unsubscribed',
|
||||
subscriber: {
|
||||
id: subscriber.id,
|
||||
email: subscriber.email,
|
||||
name: subscriber.name,
|
||||
status: subscriber.status,
|
||||
unsubscribed_at: subscriber.unsubscribed_at
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
},
|
||||
async unsubscribeByEmail(req, res) {
|
||||
try {
|
||||
const { email } = req.body;
|
||||
|
||||
if (!email) {
|
||||
return res.status(400).json({ error: 'Email is required' });
|
||||
}
|
||||
|
||||
const subscriber = await Subscriber.findOne({ where: { email } });
|
||||
|
||||
if (!subscriber) {
|
||||
return res.status(404).json({ error: 'Subscriber not found' });
|
||||
}
|
||||
|
||||
// Обновляем статус на "unsubscribed" и устанавливаем время отписки
|
||||
await subscriber.update({
|
||||
status: 'unsubscribed',
|
||||
unsubscribed_at: new Date()
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: 'Subscriber successfully unsubscribed',
|
||||
subscriber: {
|
||||
id: subscriber.id,
|
||||
email: subscriber.email,
|
||||
name: subscriber.name,
|
||||
status: subscriber.status,
|
||||
unsubscribed_at: subscriber.unsubscribed_at
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -8,5 +8,7 @@ router.get('/', subscriberController.getAll);
|
||||
router.get('/:id', subscriberController.getById);
|
||||
router.put('/:id', subscriberController.update);
|
||||
router.delete('/:id', subscriberController.delete);
|
||||
router.patch('/:id/unsubscribe', subscriberController.unsubscribe);
|
||||
router.post('/unsubscribe', subscriberController.unsubscribeByEmail);
|
||||
|
||||
export default router;
|
||||
@ -265,22 +265,38 @@ async function processEmailTask(task, topic) {
|
||||
// Добавляем трекинг-пиксель для отслеживания открытия письма
|
||||
const trackingPixel = `<img src="https://${domain}/api/mail/track/open/${deliveryLog.id}" width="1" height="1" style="display:none;" />`;
|
||||
|
||||
// Обрабатываем ссылки для отслеживания кликов
|
||||
// Обрабатываем ссылки для отслеживания кликов (исключаем ссылки отписки)
|
||||
const htmlWithClickTracking = task.html.replace(
|
||||
/<a\s+href=["']([^"']+)["']/gi,
|
||||
(match, url) => {
|
||||
// Не отслеживаем ссылки отписки
|
||||
if (url.includes('/unsubscribe')) {
|
||||
return match;
|
||||
}
|
||||
const trackingUrl = `https://${domain}/api/mail/track/click/${deliveryLog.id}?url=${encodeURIComponent(url)}`;
|
||||
return `<a href="${trackingUrl}"`;
|
||||
}
|
||||
);
|
||||
|
||||
const htmlWithTracking = htmlWithClickTracking + trackingPixel;
|
||||
// Добавляем ссылку для отписки в конец письма
|
||||
const unsubscribeLink = `
|
||||
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb; text-align: center; font-size: 12px; color: #6b7280;">
|
||||
<p style="margin: 0 0 10px 0;">Если вы больше не хотите получать наши письма, вы можете
|
||||
<a href="https://${domain}/unsubscribe?email=${encodeURIComponent(task.email)}" style="color: #ef4444; text-decoration: none;">отписаться от рассылки</a>.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const htmlWithTracking = htmlWithClickTracking + trackingPixel + unsubscribeLink;
|
||||
|
||||
// Добавляем ссылку для отписки в текстовую версию письма
|
||||
const textWithUnsubscribe = task.text + `\n\n---\nЕсли вы больше не хотите получать наши письма, вы можете отписаться от рассылки: https://${domain}/unsubscribe?email=${encodeURIComponent(task.email)}`;
|
||||
|
||||
const mailOptions = {
|
||||
from: smtp.from_email,
|
||||
to: task.email,
|
||||
subject: task.subject,
|
||||
text: task.text,
|
||||
text: textWithUnsubscribe,
|
||||
html: htmlWithTracking,
|
||||
headers: {
|
||||
'List-Unsubscribe': `<mailto:${smtp.from_email}?subject=unsubscribe>`,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user