feat: подписка на уведомление о поступлении товара
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -16,6 +16,7 @@
|
|||||||
- Личный кабинет: профиль, бронирования
|
- Личный кабинет: профиль, бронирования
|
||||||
- Роли клиент / администратор, админ-панель
|
- Роли клиент / администратор, админ-панель
|
||||||
- Согласие на cookies
|
- Согласие на cookies
|
||||||
|
- Подписка «сообщить о поступлении», если товара нет в наличии
|
||||||
|
|
||||||
## Требования
|
## Требования
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Подписка «сообщить о поступлении»
|
||||||
|
CREATE TABLE IF NOT EXISTS product_stock_alerts (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
notified_at TIMESTAMPTZ,
|
||||||
|
UNIQUE (product_id, email)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stock_alerts_product_pending
|
||||||
|
ON product_stock_alerts (product_id)
|
||||||
|
WHERE notified_at IS NULL;
|
||||||
@@ -281,6 +281,39 @@ a:hover {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stock-notify {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-notify__title {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-notify__hint {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-notify__form {
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-notify--done {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: rgba(0, 184, 148, 0.12);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stock-form {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
.product-detail__form {
|
.product-detail__form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
+32
-1
@@ -3,6 +3,7 @@ const { query, formatPrice } = require('../db');
|
|||||||
const { requireAdmin } = require('../middleware/auth');
|
const { requireAdmin } = require('../middleware/auth');
|
||||||
const { asyncHandler } = require('../utils/asyncHandler');
|
const { asyncHandler } = require('../utils/asyncHandler');
|
||||||
const { ROLE_LABELS } = require('../constants/roles');
|
const { ROLE_LABELS } = require('../constants/roles');
|
||||||
|
const { notifyIfBackInStock } = require('../services/stock-alerts');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -91,7 +92,9 @@ router.get(
|
|||||||
'/products',
|
'/products',
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const { rows: products } = await query(
|
const { rows: products } = await query(
|
||||||
`SELECT p.*, c.name AS category_name
|
`SELECT p.*, c.name AS category_name,
|
||||||
|
(SELECT COUNT(*)::int FROM product_stock_alerts a
|
||||||
|
WHERE a.product_id = p.id AND a.notified_at IS NULL) AS alert_count
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN categories c ON c.id = p.category_id
|
LEFT JOIN categories c ON c.id = p.category_id
|
||||||
ORDER BY p.id`
|
ORDER BY p.id`
|
||||||
@@ -100,10 +103,38 @@ router.get(
|
|||||||
title: 'Товары',
|
title: 'Товары',
|
||||||
products,
|
products,
|
||||||
formatPrice,
|
formatPrice,
|
||||||
|
stockUpdated: req.query.stock_updated === '1',
|
||||||
|
notified: req.query.notified ? parseInt(req.query.notified, 10) : 0,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/products/:id/stock',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const productId = parseInt(req.params.id, 10);
|
||||||
|
const stock = parseInt(req.body.stock, 10);
|
||||||
|
if (!Number.isFinite(productId) || !Number.isFinite(stock) || stock < 0) {
|
||||||
|
return res.redirect('/admin/products');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows } = await query('SELECT stock FROM products WHERE id = $1', [productId]);
|
||||||
|
const oldStock = rows[0]?.stock ?? 0;
|
||||||
|
|
||||||
|
await query('UPDATE products SET stock = $1 WHERE id = $2', [stock, productId]);
|
||||||
|
|
||||||
|
let notified = 0;
|
||||||
|
if (oldStock <= 0 && stock > 0) {
|
||||||
|
const result = await notifyIfBackInStock(productId);
|
||||||
|
notified = result.sent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const qs = new URLSearchParams({ stock_updated: '1' });
|
||||||
|
if (notified > 0) qs.set('notified', String(notified));
|
||||||
|
res.redirect(`/admin/products?${qs}`);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/reservations',
|
'/reservations',
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
|
|||||||
@@ -89,6 +89,25 @@ router.get(
|
|||||||
|
|
||||||
const errorMsg = req.query.error ? decodeURIComponent(String(req.query.error)) : null;
|
const errorMsg = req.query.error ? decodeURIComponent(String(req.query.error)) : null;
|
||||||
const reserved = req.query.reserved === '1';
|
const reserved = req.query.reserved === '1';
|
||||||
|
const notifySuccess = req.query.notify_success
|
||||||
|
? decodeURIComponent(String(req.query.notify_success))
|
||||||
|
: null;
|
||||||
|
const notifyError = req.query.notify_error
|
||||||
|
? decodeURIComponent(String(req.query.notify_error))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
let stockAlertSubscribed = false;
|
||||||
|
let notifyEmail = res.locals.user?.email || '';
|
||||||
|
if (product.stock <= 0) {
|
||||||
|
const stockAlerts = require('../services/stock-alerts');
|
||||||
|
if (notifyEmail) {
|
||||||
|
stockAlertSubscribed = await stockAlerts.isSubscribed(
|
||||||
|
product.id,
|
||||||
|
notifyEmail,
|
||||||
|
req.session.userId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.render('product', {
|
res.render('product', {
|
||||||
title: product.name,
|
title: product.name,
|
||||||
@@ -96,6 +115,10 @@ router.get(
|
|||||||
userReservation,
|
userReservation,
|
||||||
error: errorMsg,
|
error: errorMsg,
|
||||||
reserved,
|
reserved,
|
||||||
|
notifySuccess,
|
||||||
|
notifyError,
|
||||||
|
stockAlertSubscribed,
|
||||||
|
notifyEmail,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { query, formatPrice } = require('../db');
|
||||||
|
const { getCart, cartCount } = require('../cart');
|
||||||
|
const { requireCookieConsent } = require('../middleware/cookieConsent');
|
||||||
|
const { asyncHandler } = require('../utils/asyncHandler');
|
||||||
|
const stockAlerts = require('../services/stock-alerts');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.use((req, res, next) => {
|
||||||
|
const cart = getCart(req);
|
||||||
|
res.locals.cartCount = cartCount(cart);
|
||||||
|
res.locals.formatPrice = formatPrice;
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/product/:slug/notify-stock',
|
||||||
|
requireCookieConsent,
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const slug = req.params.slug;
|
||||||
|
const { rows } = await query('SELECT id, name, stock FROM products WHERE slug = $1', [
|
||||||
|
slug,
|
||||||
|
]);
|
||||||
|
const product = rows[0];
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
return res.status(404).render('error', {
|
||||||
|
title: 'Не найдено',
|
||||||
|
message: 'Товар не найден',
|
||||||
|
code: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (product.stock > 0) {
|
||||||
|
return res.redirect(
|
||||||
|
`/product/${slug}?notify_error=${encodeURIComponent('Товар уже в наличии')}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let email = (req.body.email || '').trim().toLowerCase();
|
||||||
|
if (req.session.userId) {
|
||||||
|
const { rows: users } = await query('SELECT email FROM users WHERE id = $1', [
|
||||||
|
req.session.userId,
|
||||||
|
]);
|
||||||
|
if (users[0]) email = users[0].email;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!email || !emailRe.test(email)) {
|
||||||
|
return res.redirect(
|
||||||
|
`/product/${slug}?notify_error=${encodeURIComponent('Укажите корректный email')}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await stockAlerts.isSubscribed(product.id, email, req.session.userId)) {
|
||||||
|
return res.redirect(
|
||||||
|
`/product/${slug}?notify_success=${encodeURIComponent('Вы уже подписаны — сообщим на почту')}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await stockAlerts.subscribe({
|
||||||
|
productId: product.id,
|
||||||
|
email,
|
||||||
|
userId: req.session.userId || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.redirect(
|
||||||
|
`/product/${slug}?notify_success=${encodeURIComponent('Когда товар появится, отправим письмо на ' + email)}`
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -18,6 +18,7 @@ const cookiesRoutes = require('./routes/cookies');
|
|||||||
const passwordResetRoutes = require('./routes/password-reset');
|
const passwordResetRoutes = require('./routes/password-reset');
|
||||||
const reservationsRoutes = require('./routes/reservations');
|
const reservationsRoutes = require('./routes/reservations');
|
||||||
const passkeyRoutes = require('./routes/passkey');
|
const passkeyRoutes = require('./routes/passkey');
|
||||||
|
const stockAlertsRoutes = require('./routes/stock-alerts');
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
const HOST = process.env.HOST || '0.0.0.0';
|
const HOST = process.env.HOST || '0.0.0.0';
|
||||||
@@ -68,6 +69,7 @@ async function start() {
|
|||||||
app.use('/cookies', cookiesRoutes);
|
app.use('/cookies', cookiesRoutes);
|
||||||
app.use('/', passwordResetRoutes);
|
app.use('/', passwordResetRoutes);
|
||||||
app.use('/reservations', reservationsRoutes);
|
app.use('/reservations', reservationsRoutes);
|
||||||
|
app.use('/', stockAlertsRoutes);
|
||||||
app.use('/', shopRoutes);
|
app.use('/', shopRoutes);
|
||||||
app.use('/', authRoutes);
|
app.use('/', authRoutes);
|
||||||
app.use('/webauthn', passkeyRoutes);
|
app.use('/webauthn', passkeyRoutes);
|
||||||
|
|||||||
@@ -68,10 +68,22 @@ async function sendReservationEmail(to, productName, quantity, expiresAt) {
|
|||||||
return sendMail({ to, subject, text, html });
|
return sendMail({ to, subject, text, html });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function sendStockAvailableEmail(to, productName, productUrl) {
|
||||||
|
const subject = `Снова в наличии: ${productName}`;
|
||||||
|
const text = `Товар «${productName}» снова в наличии.\n\nПерейти: ${productUrl}`;
|
||||||
|
const html = `
|
||||||
|
<p>Товар <strong>${productName}</strong> снова в наличии.</p>
|
||||||
|
<p><a href="${productUrl}">Открыть товар в магазине</a></p>
|
||||||
|
<p style="color:#666">Вы получили это письмо, потому что подписались на уведомление о поступлении.</p>
|
||||||
|
`;
|
||||||
|
return sendMail({ to, subject, text, html });
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
isConfigured,
|
isConfigured,
|
||||||
sendMail,
|
sendMail,
|
||||||
sendPasswordResetEmail,
|
sendPasswordResetEmail,
|
||||||
sendReservationEmail,
|
sendReservationEmail,
|
||||||
|
sendStockAvailableEmail,
|
||||||
siteUrl,
|
siteUrl,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
const { query } = require('../db');
|
||||||
|
const { sendStockAvailableEmail, siteUrl } = require('./mail');
|
||||||
|
|
||||||
|
async function isSubscribed(productId, email, userId) {
|
||||||
|
const normalized = email.trim().toLowerCase();
|
||||||
|
const params = [productId, normalized];
|
||||||
|
let sql = `SELECT 1 FROM product_stock_alerts
|
||||||
|
WHERE product_id = $1 AND notified_at IS NULL
|
||||||
|
AND (email = $2`;
|
||||||
|
if (userId) {
|
||||||
|
sql += ' OR user_id = $3';
|
||||||
|
params.push(userId);
|
||||||
|
}
|
||||||
|
sql += ')';
|
||||||
|
const { rows } = await query(sql, params);
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function subscribe({ productId, email, userId }) {
|
||||||
|
const normalized = email.trim().toLowerCase();
|
||||||
|
await query(
|
||||||
|
`INSERT INTO product_stock_alerts (product_id, email, user_id)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (product_id, email) DO UPDATE SET
|
||||||
|
user_id = COALESCE(EXCLUDED.user_id, product_stock_alerts.user_id),
|
||||||
|
notified_at = NULL,
|
||||||
|
created_at = CASE
|
||||||
|
WHEN product_stock_alerts.notified_at IS NOT NULL THEN NOW()
|
||||||
|
ELSE product_stock_alerts.created_at
|
||||||
|
END`,
|
||||||
|
[productId, normalized, userId || null]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function notifyIfBackInStock(productId) {
|
||||||
|
const { rows: products } = await query(
|
||||||
|
'SELECT id, slug, name, stock FROM products WHERE id = $1',
|
||||||
|
[productId]
|
||||||
|
);
|
||||||
|
const product = products[0];
|
||||||
|
if (!product || product.stock <= 0) return { sent: 0 };
|
||||||
|
|
||||||
|
const { rows: alerts } = await query(
|
||||||
|
`SELECT id, email FROM product_stock_alerts
|
||||||
|
WHERE product_id = $1 AND notified_at IS NULL`,
|
||||||
|
[productId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!alerts.length) return { sent: 0 };
|
||||||
|
|
||||||
|
const productUrl = `${siteUrl()}/product/${product.slug}`;
|
||||||
|
let sent = 0;
|
||||||
|
|
||||||
|
for (const alert of alerts) {
|
||||||
|
try {
|
||||||
|
await sendStockAvailableEmail(alert.email, product.name, productUrl);
|
||||||
|
await query(
|
||||||
|
'UPDATE product_stock_alerts SET notified_at = NOW() WHERE id = $1',
|
||||||
|
[alert.id]
|
||||||
|
);
|
||||||
|
sent++;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('stock alert email failed:', alert.email, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sent };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
isSubscribed,
|
||||||
|
subscribe,
|
||||||
|
notifyIfBackInStock,
|
||||||
|
};
|
||||||
@@ -12,6 +12,12 @@
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<% if (stockUpdated) { %>
|
||||||
|
<p class="alert alert--success">
|
||||||
|
Остаток обновлён.<% if (notified > 0) { %> Отправлено уведомлений подписчикам: <%= notified %>.<% } %>
|
||||||
|
</p>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
<table class="cart-table">
|
<table class="cart-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -20,6 +26,7 @@
|
|||||||
<th>Категория</th>
|
<th>Категория</th>
|
||||||
<th>Цена</th>
|
<th>Цена</th>
|
||||||
<th>Остаток</th>
|
<th>Остаток</th>
|
||||||
|
<th>Подписки</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -30,7 +37,19 @@
|
|||||||
<td><%= p.name %></td>
|
<td><%= p.name %></td>
|
||||||
<td><%= p.category_name || '—' %></td>
|
<td><%= p.category_name || '—' %></td>
|
||||||
<td><%= formatPrice(p.price_cents) %></td>
|
<td><%= formatPrice(p.price_cents) %></td>
|
||||||
<td><%= p.stock %></td>
|
<td>
|
||||||
|
<form action="/admin/products/<%= p.id %>/stock" method="post" class="inline-form admin-stock-form">
|
||||||
|
<input type="number" name="stock" class="input input--sm" min="0" value="<%= p.stock %>" aria-label="Остаток">
|
||||||
|
<button type="submit" class="btn btn--ghost btn--sm">OK</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<% if (p.alert_count > 0) { %>
|
||||||
|
<span class="badge" title="Ждут уведомления"><%= p.alert_count %></span>
|
||||||
|
<% } else { %>
|
||||||
|
—
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
<td><a href="/product/<%= p.slug %>">На сайте</a></td>
|
<td><a href="/product/<%= p.slug %>">На сайте</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
<p class="product-detail__stock">В наличии: <strong><%= product.stock %></strong> шт.</p>
|
<p class="product-detail__stock">В наличии: <strong><%= product.stock %></strong> шт.</p>
|
||||||
|
|
||||||
<% if (error) { %><p class="alert alert--error"><%= error %></p><% } %>
|
<% if (error) { %><p class="alert alert--error"><%= error %></p><% } %>
|
||||||
|
<% if (notifySuccess) { %><p class="alert alert--success"><%= notifySuccess %></p><% } %>
|
||||||
|
<% if (notifyError) { %><p class="alert alert--error"><%= notifyError %></p><% } %>
|
||||||
<% if (reserved) { %><p class="alert alert--success">Товар успешно забронирован. Подробности на почте и в личном кабинете.</p><% } %>
|
<% if (reserved) { %><p class="alert alert--success">Товар успешно забронирован. Подробности на почте и в личном кабинете.</p><% } %>
|
||||||
<% if (userReservation) { %>
|
<% if (userReservation) { %>
|
||||||
<p class="alert alert--success">
|
<p class="alert alert--success">
|
||||||
@@ -52,6 +54,31 @@
|
|||||||
<% } %>
|
<% } %>
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
<p class="alert alert--warn">Нет в наличии</p>
|
<p class="alert alert--warn">Нет в наличии</p>
|
||||||
|
|
||||||
|
<% if (typeof cookieConsent !== 'undefined' && !cookieConsent) { %>
|
||||||
|
<p class="muted">Примите cookies, чтобы подписаться на уведомление о поступлении.</p>
|
||||||
|
<% } else if (stockAlertSubscribed) { %>
|
||||||
|
<p class="stock-notify stock-notify--done">
|
||||||
|
Вы подписаны на уведомление<% if (notifyEmail) { %> — письмо придёт на <strong><%= notifyEmail %></strong><% } %>.
|
||||||
|
</p>
|
||||||
|
<% } else { %>
|
||||||
|
<section class="stock-notify card">
|
||||||
|
<h2 class="stock-notify__title">Сообщить о поступлении</h2>
|
||||||
|
<p class="muted stock-notify__hint">Будьте среди первых, кто узнает, когда товар снова появится в наличии.</p>
|
||||||
|
<form action="/product/<%= product.slug %>/notify-stock" method="post" class="form stock-notify__form">
|
||||||
|
<% if (user && notifyEmail) { %>
|
||||||
|
<p class="muted">Уведомление отправим на <strong><%= notifyEmail %></strong></p>
|
||||||
|
<input type="hidden" name="email" value="<%= notifyEmail %>">
|
||||||
|
<% } else { %>
|
||||||
|
<label class="label">
|
||||||
|
Email
|
||||||
|
<input type="email" name="email" class="input" required autocomplete="email" placeholder="you@example.com" value="<%= notifyEmail || '' %>">
|
||||||
|
</label>
|
||||||
|
<% } %>
|
||||||
|
<button type="submit" class="btn btn--primary">Подписаться</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<% } %>
|
||||||
<% } %>
|
<% } %>
|
||||||
<a href="/" class="link-back">← Назад в каталог</a>
|
<a href="/" class="link-back">← Назад в каталог</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user