feat: подписка на уведомление о поступлении товара

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
shop
2026-05-17 13:38:03 +03:00
parent 561fbd22e0
commit e2a7c79245
11 changed files with 313 additions and 2 deletions
+1
View File
@@ -16,6 +16,7 @@
- Личный кабинет: профиль, бронирования - Личный кабинет: профиль, бронирования
- Роли клиент / администратор, админ-панель - Роли клиент / администратор, админ-панель
- Согласие на cookies - Согласие на cookies
- Подписка «сообщить о поступлении», если товара нет в наличии
## Требования ## Требования
+14
View File
@@ -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;
+33
View File
@@ -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
View File
@@ -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) => {
+23
View File
@@ -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,
}); });
}) })
); );
+75
View File
@@ -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;
+2
View File
@@ -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);
+12
View File
@@ -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,
}; };
+74
View File
@@ -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,
};
+20 -1
View File
@@ -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>
<% }) %> <% }) %>
+27
View File
@@ -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>