feat: бронирование товаров и сброс пароля по email

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
shop
2026-05-17 11:38:52 +03:00
parent bda73e1662
commit ade031b0e7
22 changed files with 666 additions and 3 deletions
+40
View File
@@ -10,6 +10,7 @@
<a href="/account?tab=profile" class="account-tabs__link <%= activeTab === 'profile' ? 'account-tabs__link--active' : '' %>">Профиль</a>
<a href="/account?tab=email" class="account-tabs__link <%= activeTab === 'email' ? 'account-tabs__link--active' : '' %>">Смена email</a>
<a href="/account?tab=password" class="account-tabs__link <%= activeTab === 'password' ? 'account-tabs__link--active' : '' %>">Смена пароля</a>
<a href="/account?tab=reservations" class="account-tabs__link <%= activeTab === 'reservations' ? 'account-tabs__link--active' : '' %>">Бронирования</a>
</nav>
<% if (activeTab === 'profile') { %>
@@ -62,6 +63,45 @@
</section>
<% } %>
<% if (activeTab === 'reservations') { %>
<section class="card account-section">
<h2>Мои бронирования</h2>
<% if (!reservations.length) { %>
<p class="muted">Активных броней нет.</p>
<% } else { %>
<table class="cart-table">
<thead>
<tr>
<th>Товар</th>
<th>Кол-во</th>
<th>Статус</th>
<th>До</th>
<th></th>
</tr>
</thead>
<tbody>
<% const resStatus = { active: 'Активна', fulfilled: 'Выполнена', cancelled: 'Отменена', expired: 'Истекла' }; %>
<% reservations.forEach(r => { %>
<tr>
<td><a href="/product/<%= r.product_slug %>"><%= r.product_name %></a></td>
<td><%= r.quantity %></td>
<td><span class="status status--<%= r.status === 'active' ? 'pending' : r.status %>"><%= resStatus[r.status] || r.status %></span></td>
<td><%= r.status === 'active' ? new Date(r.expires_at).toLocaleString('ru-RU') : '—' %></td>
<td>
<% if (r.status === 'active') { %>
<form action="/reservations/<%= r.id %>/cancel" method="post" class="inline-form">
<button type="submit" class="btn btn--ghost btn--sm">Отменить</button>
</form>
<% } %>
</td>
</tr>
<% }) %>
</tbody>
</table>
<% } %>
</section>
<% } %>
<% if (activeTab === 'password') { %>
<section class="card account-section account-section--narrow">
<h2>Смена пароля</h2>
+1
View File
@@ -7,6 +7,7 @@
<a href="/admin/orders" class="admin-nav__link">Заказы</a>
<a href="/admin/users" class="admin-nav__link">Пользователи</a>
<a href="/admin/products" class="admin-nav__link">Товары</a>
<a href="/admin/reservations" class="admin-nav__link">Бронирования</a>
<a href="/" class="admin-nav__link">В магазин</a>
</nav>
</div>
+1
View File
@@ -7,6 +7,7 @@
<a href="/admin/orders" class="admin-nav__link admin-nav__link--active">Заказы</a>
<a href="/admin/users" class="admin-nav__link">Пользователи</a>
<a href="/admin/products" class="admin-nav__link">Товары</a>
<a href="/admin/reservations" class="admin-nav__link">Бронирования</a>
<a href="/" class="admin-nav__link">В магазин</a>
</nav>
</div>
+1
View File
@@ -7,6 +7,7 @@
<a href="/admin/orders" class="admin-nav__link">Заказы</a>
<a href="/admin/users" class="admin-nav__link">Пользователи</a>
<a href="/admin/products" class="admin-nav__link admin-nav__link--active">Товары</a>
<a href="/admin/reservations" class="admin-nav__link">Бронирования</a>
<a href="/" class="admin-nav__link">В магазин</a>
</nav>
</div>
+53
View File
@@ -0,0 +1,53 @@
<%- include('../partials/layout-start') %>
<div class="admin-header">
<h1>Бронирования</h1>
<nav class="admin-nav">
<a href="/admin" class="admin-nav__link">Обзор</a>
<a href="/admin/orders" class="admin-nav__link">Заказы</a>
<a href="/admin/users" class="admin-nav__link">Пользователи</a>
<a href="/admin/products" class="admin-nav__link">Товары</a>
<a href="/admin/reservations" class="admin-nav__link admin-nav__link--active">Бронирования</a>
<a href="/" class="admin-nav__link">В магазин</a>
</nav>
</div>
<% const resStatus = { active: 'Активна', fulfilled: 'Выполнена', cancelled: 'Отменена', expired: 'Истекла' }; %>
<table class="cart-table">
<thead>
<tr>
<th>№</th>
<th>Клиент</th>
<th>Товар</th>
<th>Кол-во</th>
<th>Статус</th>
<th>До</th>
<th>Действие</th>
</tr>
</thead>
<tbody>
<% reservations.forEach(r => { %>
<tr>
<td>#<%= r.id %></td>
<td><%= r.user_name %><br><span class="muted"><%= r.user_email %></span></td>
<td><%= r.product_name %></td>
<td><%= r.quantity %></td>
<td><span class="status status--<%= r.status === 'active' ? 'pending' : r.status %>"><%= resStatus[r.status] || r.status %></span></td>
<td><%= r.status === 'active' ? new Date(r.expires_at).toLocaleString('ru-RU') : '—' %></td>
<td>
<form method="post" action="/admin/reservations/<%= r.id %>/status" class="admin-status-form">
<select name="status" class="input input--sm">
<% ['active','fulfilled','cancelled','expired'].forEach(s => { %>
<option value="<%= s %>" <%= r.status === s ? 'selected' : '' %>><%= resStatus[s] %></option>
<% }) %>
</select>
<button type="submit" class="btn btn--ghost btn--sm">OK</button>
</form>
</td>
</tr>
<% }) %>
</tbody>
</table>
<%- include('../partials/layout-end') %>
+1
View File
@@ -7,6 +7,7 @@
<a href="/admin/orders" class="admin-nav__link">Заказы</a>
<a href="/admin/users" class="admin-nav__link admin-nav__link--active">Пользователи</a>
<a href="/admin/products" class="admin-nav__link">Товары</a>
<a href="/admin/reservations" class="admin-nav__link">Бронирования</a>
<a href="/" class="admin-nav__link">В магазин</a>
</nav>
</div>
+18
View File
@@ -0,0 +1,18 @@
<%- include('../partials/layout-start') %>
<div class="auth">
<form action="/forgot-password" method="post" class="form card">
<h1>Сброс пароля</h1>
<% if (error) { %><p class="alert alert--error"><%= error %></p><% } %>
<% if (success) { %><p class="alert alert--success"><%= success %></p><% } %>
<p class="muted">Укажите email аккаунта — отправим ссылку для нового пароля.</p>
<label class="label">
Email
<input type="email" name="email" class="input" required value="<%= values.email || '' %>" autocomplete="email">
</label>
<button type="submit" class="btn btn--primary btn--block">Отправить ссылку</button>
<p class="form-footer"><a href="/login">← Вход</a></p>
</form>
</div>
<%- include('../partials/layout-end') %>
+11
View File
@@ -0,0 +1,11 @@
<%- include('../partials/layout-start') %>
<div class="auth">
<div class="card form">
<h1>Пароль изменён</h1>
<p class="alert alert--success">Теперь можно войти с новым паролем.</p>
<a href="/login" class="btn btn--primary btn--block">Войти</a>
</div>
</div>
<%- include('../partials/layout-end') %>
+28
View File
@@ -0,0 +1,28 @@
<%- include('../partials/layout-start') %>
<div class="auth">
<% if (token) { %>
<form action="/reset-password" method="post" class="form card">
<h1>Новый пароль</h1>
<% if (error) { %><p class="alert alert--error"><%= error %></p><% } %>
<input type="hidden" name="token" value="<%= token %>">
<label class="label">
Новый пароль
<input type="password" name="password" class="input" required minlength="6" autocomplete="new-password">
</label>
<label class="label">
Повторите пароль
<input type="password" name="password2" class="input" required minlength="6" autocomplete="new-password">
</label>
<button type="submit" class="btn btn--primary btn--block">Сохранить пароль</button>
</form>
<% } else { %>
<div class="card form">
<h1>Ссылка недействительна</h1>
<% if (error) { %><p class="alert alert--error"><%= error %></p><% } %>
<a href="/forgot-password" class="btn btn--primary">Запросить снова</a>
</div>
<% } %>
</div>
<%- include('../partials/layout-end') %>
+4 -1
View File
@@ -14,7 +14,10 @@
<input type="password" name="password" class="input" required>
</label>
<button type="submit" class="btn btn--primary btn--block">Войти</button>
<p class="form-footer">Нет аккаунта? <a href="/register">Регистрация</a></p>
<p class="form-footer">
<a href="/forgot-password">Забыли пароль?</a><br>
Нет аккаунта? <a href="/register">Регистрация</a>
</p>
</form>
</div>
+23
View File
@@ -17,6 +17,15 @@
<p class="product-detail__desc"><%= product.description %></p>
<p class="product-detail__stock">В наличии: <strong><%= product.stock %></strong> шт.</p>
<% if (error) { %><p class="alert alert--error"><%= error %></p><% } %>
<% if (reserved) { %><p class="alert alert--success">Товар успешно забронирован. Подробности на почте и в личном кабинете.</p><% } %>
<% if (userReservation) { %>
<p class="alert alert--success">
У вас активная бронь: <%= userReservation.quantity %> шт. до <%= new Date(userReservation.expires_at).toLocaleString('ru-RU') %>.
<a href="/account?tab=reservations">Мои бронирования</a>
</p>
<% } %>
<% if (product.stock > 0) { %>
<form action="/cart/add" method="post" class="product-detail__form">
<input type="hidden" name="product_id" value="<%= product.id %>">
@@ -27,6 +36,20 @@
<input type="hidden" name="redirect" value="/cart">
<button type="submit" class="btn btn--primary btn--lg">Добавить в корзину</button>
</form>
<% if (user && !userReservation) { %>
<form action="/reservations" method="post" class="product-detail__form">
<input type="hidden" name="product_id" value="<%= product.id %>">
<input type="hidden" name="slug" value="<%= product.slug %>">
<label class="label">
Бронь (48 ч)
<input type="number" name="quantity" value="1" min="1" max="<%= product.stock %>" class="input input--qty">
</label>
<button type="submit" class="btn btn--ghost btn--lg">Забронировать</button>
</form>
<% } else if (!user) { %>
<p class="muted">Для бронирования <a href="/login">войдите</a> в аккаунт.</p>
<% } %>
<% } else { %>
<p class="alert alert--warn">Нет в наличии</p>
<% } %>