+34
@@ -0,0 +1,34 @@
|
||||
function getCart(req) {
|
||||
if (!req.session.cart) {
|
||||
req.session.cart = {};
|
||||
}
|
||||
return req.session.cart;
|
||||
}
|
||||
|
||||
function cartCount(cart) {
|
||||
return Object.values(cart).reduce((sum, qty) => sum + qty, 0);
|
||||
}
|
||||
|
||||
function cartItems(db, cart) {
|
||||
const ids = Object.keys(cart).map(Number).filter(Boolean);
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
const products = db
|
||||
.prepare(`SELECT * FROM products WHERE id IN (${placeholders})`)
|
||||
.all(...ids);
|
||||
|
||||
return products
|
||||
.map((p) => ({
|
||||
...p,
|
||||
quantity: cart[p.id] || 0,
|
||||
line_total: (cart[p.id] || 0) * p.price_cents,
|
||||
}))
|
||||
.filter((p) => p.quantity > 0);
|
||||
}
|
||||
|
||||
function cartTotal(items) {
|
||||
return items.reduce((sum, i) => sum + i.line_total, 0);
|
||||
}
|
||||
|
||||
module.exports = { getCart, cartCount, cartItems, cartTotal };
|
||||
@@ -0,0 +1,76 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const Database = require('better-sqlite3');
|
||||
|
||||
const dataDir = path.join(__dirname, '..', 'data');
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
const dbPath = path.join(dataDir, 'shop.db');
|
||||
const db = new Database(dbPath);
|
||||
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
password_hash TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS products (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
price_cents INTEGER NOT NULL CHECK (price_cents >= 0),
|
||||
stock INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0),
|
||||
image_url TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'paid', 'shipped', 'cancelled')),
|
||||
total_cents INTEGER NOT NULL CHECK (total_cents >= 0),
|
||||
customer_name TEXT NOT NULL,
|
||||
customer_email TEXT NOT NULL,
|
||||
customer_phone TEXT NOT NULL DEFAULT '',
|
||||
address TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS order_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
|
||||
product_id INTEGER NOT NULL REFERENCES products(id),
|
||||
quantity INTEGER NOT NULL CHECK (quantity > 0),
|
||||
price_cents INTEGER NOT NULL CHECK (price_cents >= 0)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_products_category ON products(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_user ON orders(user_id);
|
||||
`);
|
||||
|
||||
function formatPrice(cents) {
|
||||
return (cents / 100).toLocaleString('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: 'RUB',
|
||||
minimumFractionDigits: 0,
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { db, formatPrice, dbPath };
|
||||
@@ -0,0 +1,22 @@
|
||||
function requireAuth(req, res, next) {
|
||||
if (!req.session.userId) {
|
||||
const nextUrl = encodeURIComponent(req.originalUrl);
|
||||
return res.redirect(`/login?next=${nextUrl}`);
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
function loadUser(req, res, next) {
|
||||
if (req.session.userId) {
|
||||
const { db } = require('../db');
|
||||
const user = db
|
||||
.prepare('SELECT id, email, name FROM users WHERE id = ?')
|
||||
.get(req.session.userId);
|
||||
res.locals.user = user || null;
|
||||
} else {
|
||||
res.locals.user = null;
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = { requireAuth, loadUser };
|
||||
@@ -0,0 +1,581 @@
|
||||
:root {
|
||||
--bg: #0f0f12;
|
||||
--surface: #1a1a21;
|
||||
--surface-2: #24242e;
|
||||
--border: #2e2e3a;
|
||||
--text: #f4f4f6;
|
||||
--muted: #9b9bab;
|
||||
--accent: #6c5ce7;
|
||||
--accent-hover: #7f70f0;
|
||||
--success: #00b894;
|
||||
--warn: #fdcb6e;
|
||||
--error: #ff7675;
|
||||
--radius: 12px;
|
||||
--shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
|
||||
font-family: 'DM Sans', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent-hover);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: min(1200px, 100% - 2rem);
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: rgba(15, 15, 18, 0.9);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.header__inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.85rem 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.logo:hover {
|
||||
color: var(--accent-hover);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.search {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
min-width: 200px;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.search input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.nav__link {
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav__link:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav__cart {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
padding: 0 0.35rem;
|
||||
margin-left: 0.25rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
padding: 2rem 0 3rem;
|
||||
}
|
||||
|
||||
.footer {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 1.5rem 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: clamp(1.75rem, 4vw, 2.25rem);
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.categories {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
|
||||
.chip {
|
||||
padding: 0.4rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.chip:hover,
|
||||
.chip--active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.card__image-wrap {
|
||||
display: block;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.card__image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.card__placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.card__body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.card__category {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.card__title {
|
||||
margin: 0.35rem 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card__title a {
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.card__title a:hover {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.card__price {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.card__form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.product-detail {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.product-detail {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.product-detail__image {
|
||||
width: 100%;
|
||||
border-radius: var(--radius);
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.product-detail__price {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.product-detail__desc {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.product-detail__form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 1rem;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.link-back {
|
||||
display: inline-block;
|
||||
margin-top: 1rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.55rem 1.1rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn--primary:hover {
|
||||
background: var(--accent-hover);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn--ghost:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--muted);
|
||||
}
|
||||
|
||||
.btn--block {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn--sm {
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.btn--lg {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.85rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.input--qty {
|
||||
width: 4.5rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.form h1,
|
||||
.form h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.auth {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.auth .form {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 1.75rem;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.alert--error {
|
||||
background: rgba(255, 118, 117, 0.15);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.alert--success {
|
||||
background: rgba(0, 184, 148, 0.15);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.alert--warn {
|
||||
background: rgba(253, 203, 110, 0.15);
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--muted);
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.cart-table-wrap {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.cart-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cart-table th,
|
||||
.cart-table td {
|
||||
padding: 0.85rem 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.cart-table th {
|
||||
background: var(--surface-2);
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.cart-table__product {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.cart-table__thumb {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.cart-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.cart-total {
|
||||
margin-left: auto;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.checkout-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 320px;
|
||||
gap: 2rem;
|
||||
align-items: start;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.checkout-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.checkout-summary {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.checkout-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.checkout-list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.checkout-total {
|
||||
margin-top: 1rem;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.account-card {
|
||||
padding: 1.5rem;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.account-actions {
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status--pending {
|
||||
background: rgba(253, 203, 110, 0.2);
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.status--paid,
|
||||
.status--shipped {
|
||||
background: rgba(0, 184, 148, 0.2);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status--cancelled {
|
||||
background: rgba(255, 118, 117, 0.2);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.order-card {
|
||||
padding: 1.5rem;
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.error-page {
|
||||
text-align: center;
|
||||
padding: 4rem 0;
|
||||
}
|
||||
|
||||
.error-page h1 {
|
||||
font-size: 4rem;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.inline-form {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const { db } = require('../db');
|
||||
const { getCart, cartCount } = require('../cart');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use((req, res, next) => {
|
||||
const cart = getCart(req);
|
||||
res.locals.cartCount = cartCount(cart);
|
||||
res.locals.formatPrice = require('../db').formatPrice;
|
||||
next();
|
||||
});
|
||||
|
||||
router.get('/register', (req, res) => {
|
||||
if (req.session.userId) return res.redirect('/account');
|
||||
res.render('register', { title: 'Регистрация', error: null, values: {} });
|
||||
});
|
||||
|
||||
router.post('/register', (req, res) => {
|
||||
const { name, email, password, password2 } = req.body;
|
||||
const values = { name, email };
|
||||
|
||||
if (!name?.trim() || !email?.trim() || !password) {
|
||||
return res.status(400).render('register', {
|
||||
title: 'Регистрация',
|
||||
error: 'Заполните все поля',
|
||||
values,
|
||||
});
|
||||
}
|
||||
if (password.length < 6) {
|
||||
return res.status(400).render('register', {
|
||||
title: 'Регистрация',
|
||||
error: 'Пароль не менее 6 символов',
|
||||
values,
|
||||
});
|
||||
}
|
||||
if (password !== password2) {
|
||||
return res.status(400).render('register', {
|
||||
title: 'Регистрация',
|
||||
error: 'Пароли не совпадают',
|
||||
values,
|
||||
});
|
||||
}
|
||||
|
||||
const hash = bcrypt.hashSync(password, 10);
|
||||
try {
|
||||
const r = db
|
||||
.prepare('INSERT INTO users (email, password_hash, name) VALUES (?, ?, ?)')
|
||||
.run(email.trim().toLowerCase(), hash, name.trim());
|
||||
req.session.userId = r.lastInsertRowid;
|
||||
res.redirect('/');
|
||||
} catch (err) {
|
||||
if (err.code === 'SQLITE_CONSTRAINT_UNIQUE') {
|
||||
return res.status(400).render('register', {
|
||||
title: 'Регистрация',
|
||||
error: 'Этот email уже зарегистрирован',
|
||||
values,
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/login', (req, res) => {
|
||||
if (req.session.userId) return res.redirect('/account');
|
||||
res.render('login', {
|
||||
title: 'Вход',
|
||||
error: null,
|
||||
next: req.query.next || '/',
|
||||
values: {},
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/login', (req, res) => {
|
||||
const { email, password } = req.body;
|
||||
const next = req.body.next || '/';
|
||||
const values = { email };
|
||||
|
||||
const user = db
|
||||
.prepare('SELECT * FROM users WHERE email = ?')
|
||||
.get((email || '').trim().toLowerCase());
|
||||
|
||||
if (!user || !bcrypt.compareSync(password || '', user.password_hash)) {
|
||||
return res.status(401).render('login', {
|
||||
title: 'Вход',
|
||||
error: 'Неверный email или пароль',
|
||||
next,
|
||||
values,
|
||||
});
|
||||
}
|
||||
|
||||
req.session.userId = user.id;
|
||||
res.redirect(next.startsWith('/') ? next : '/');
|
||||
});
|
||||
|
||||
router.post('/logout', (req, res) => {
|
||||
req.session.destroy(() => {
|
||||
res.redirect('/');
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/account', requireAuth, (req, res) => {
|
||||
const user = db
|
||||
.prepare('SELECT id, email, name, created_at FROM users WHERE id = ?')
|
||||
.get(req.session.userId);
|
||||
|
||||
const orderCount = db
|
||||
.prepare('SELECT COUNT(*) AS n FROM orders WHERE user_id = ?')
|
||||
.get(user.id).n;
|
||||
|
||||
res.render('account', { title: 'Личный кабинет', user, orderCount });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,267 @@
|
||||
const express = require('express');
|
||||
const { db, formatPrice } = require('../db');
|
||||
const { getCart, cartCount, cartItems, cartTotal } = require('../cart');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function enrichLocals(req, res) {
|
||||
const cart = getCart(req);
|
||||
res.locals.cartCount = cartCount(cart);
|
||||
res.locals.formatPrice = formatPrice;
|
||||
}
|
||||
|
||||
router.use((req, res, next) => {
|
||||
enrichLocals(req, res);
|
||||
next();
|
||||
});
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
const category = req.query.category || '';
|
||||
const q = (req.query.q || '').trim();
|
||||
|
||||
let sql = `
|
||||
SELECT p.*, c.name AS category_name, c.slug AS category_slug
|
||||
FROM products p
|
||||
LEFT JOIN categories c ON c.id = p.category_id
|
||||
WHERE p.stock > 0
|
||||
`;
|
||||
const params = [];
|
||||
|
||||
if (category) {
|
||||
sql += ' AND c.slug = ?';
|
||||
params.push(category);
|
||||
}
|
||||
if (q) {
|
||||
sql += ' AND (p.name LIKE ? OR p.description LIKE ?)';
|
||||
const like = `%${q}%`;
|
||||
params.push(like, like);
|
||||
}
|
||||
sql += ' ORDER BY p.name';
|
||||
|
||||
const products = db.prepare(sql).all(...params);
|
||||
const categories = db.prepare('SELECT * FROM categories ORDER BY name').all();
|
||||
|
||||
res.render('home', {
|
||||
title: 'Каталог',
|
||||
products,
|
||||
categories,
|
||||
activeCategory: category,
|
||||
searchQuery: q,
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/product/:slug', (req, res) => {
|
||||
const product = db
|
||||
.prepare(
|
||||
`SELECT p.*, c.name AS category_name, c.slug AS category_slug
|
||||
FROM products p
|
||||
LEFT JOIN categories c ON c.id = p.category_id
|
||||
WHERE p.slug = ?`
|
||||
)
|
||||
.get(req.params.slug);
|
||||
|
||||
if (!product) {
|
||||
return res.status(404).render('error', {
|
||||
title: 'Не найдено',
|
||||
message: 'Товар не найден',
|
||||
code: 404,
|
||||
});
|
||||
}
|
||||
|
||||
res.render('product', { title: product.name, product });
|
||||
});
|
||||
|
||||
router.get('/cart', (req, res) => {
|
||||
const cart = getCart(req);
|
||||
const items = cartItems(db, cart);
|
||||
const total = cartTotal(items);
|
||||
|
||||
const errorMsg = req.query.error ? decodeURIComponent(String(req.query.error)) : null;
|
||||
res.render('cart', {
|
||||
title: 'Корзина',
|
||||
items,
|
||||
total,
|
||||
error: errorMsg,
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/cart/add', (req, res) => {
|
||||
const productId = parseInt(req.body.product_id, 10);
|
||||
const quantity = Math.max(1, parseInt(req.body.quantity, 10) || 1);
|
||||
|
||||
const product = db
|
||||
.prepare('SELECT id, stock FROM products WHERE id = ?')
|
||||
.get(productId);
|
||||
if (!product) {
|
||||
return res.redirect('/');
|
||||
}
|
||||
|
||||
const cart = getCart(req);
|
||||
const current = cart[productId] || 0;
|
||||
const nextQty = Math.min(product.stock, current + quantity);
|
||||
cart[productId] = nextQty;
|
||||
|
||||
const redirect = req.body.redirect || '/cart';
|
||||
res.redirect(redirect);
|
||||
});
|
||||
|
||||
router.post('/cart/update', (req, res) => {
|
||||
const cart = getCart(req);
|
||||
const updates = req.body.items || {};
|
||||
|
||||
for (const [id, qty] of Object.entries(updates)) {
|
||||
const productId = parseInt(id, 10);
|
||||
const quantity = parseInt(qty, 10);
|
||||
if (!productId) continue;
|
||||
|
||||
if (!quantity || quantity <= 0) {
|
||||
delete cart[productId];
|
||||
continue;
|
||||
}
|
||||
|
||||
const product = db
|
||||
.prepare('SELECT stock FROM products WHERE id = ?')
|
||||
.get(productId);
|
||||
if (product) {
|
||||
cart[productId] = Math.min(product.stock, quantity);
|
||||
}
|
||||
}
|
||||
|
||||
res.redirect('/cart');
|
||||
});
|
||||
|
||||
router.post('/cart/remove/:id', (req, res) => {
|
||||
const cart = getCart(req);
|
||||
delete cart[parseInt(req.params.id, 10)];
|
||||
res.redirect('/cart');
|
||||
});
|
||||
|
||||
router.get('/checkout', requireAuth, (req, res) => {
|
||||
const cart = getCart(req);
|
||||
const items = cartItems(db, cart);
|
||||
if (items.length === 0) {
|
||||
return res.redirect('/cart');
|
||||
}
|
||||
|
||||
res.render('checkout', {
|
||||
title: 'Оформление заказа',
|
||||
items,
|
||||
total: cartTotal(items),
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/checkout', requireAuth, (req, res) => {
|
||||
const cart = getCart(req);
|
||||
const items = cartItems(db, cart);
|
||||
if (items.length === 0) {
|
||||
return res.redirect('/cart');
|
||||
}
|
||||
|
||||
const { name, email, phone, address } = req.body;
|
||||
if (!name?.trim() || !email?.trim() || !address?.trim()) {
|
||||
return res.status(400).render('checkout', {
|
||||
title: 'Оформление заказа',
|
||||
items,
|
||||
total: cartTotal(items),
|
||||
error: 'Заполните имя, email и адрес доставки',
|
||||
});
|
||||
}
|
||||
|
||||
const total = cartTotal(items);
|
||||
|
||||
const placeOrder = db.transaction(() => {
|
||||
for (const item of items) {
|
||||
const row = db
|
||||
.prepare('SELECT stock FROM products WHERE id = ?')
|
||||
.get(item.id);
|
||||
if (!row || row.stock < item.quantity) {
|
||||
throw new Error(`Недостаточно «${item.name}» на складе`);
|
||||
}
|
||||
}
|
||||
|
||||
const order = db
|
||||
.prepare(
|
||||
`INSERT INTO orders (user_id, status, total_cents, customer_name, customer_email, customer_phone, address)
|
||||
VALUES (?, 'pending', ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
req.session.userId,
|
||||
total,
|
||||
name.trim(),
|
||||
email.trim(),
|
||||
(phone || '').trim(),
|
||||
address.trim()
|
||||
);
|
||||
|
||||
const insertItem = db.prepare(
|
||||
`INSERT INTO order_items (order_id, product_id, quantity, price_cents)
|
||||
VALUES (?, ?, ?, ?)`
|
||||
);
|
||||
const updateStock = db.prepare(
|
||||
'UPDATE products SET stock = stock - ? WHERE id = ?'
|
||||
);
|
||||
|
||||
for (const item of items) {
|
||||
insertItem.run(order.lastInsertRowid, item.id, item.quantity, item.price_cents);
|
||||
updateStock.run(item.quantity, item.id);
|
||||
}
|
||||
|
||||
return order.lastInsertRowid;
|
||||
});
|
||||
|
||||
try {
|
||||
const orderId = placeOrder();
|
||||
req.session.cart = {};
|
||||
res.redirect(`/orders/${orderId}?success=1`);
|
||||
} catch (err) {
|
||||
res.redirect(`/cart?error=${encodeURIComponent(err.message)}`);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/orders', requireAuth, (req, res) => {
|
||||
const orders = db
|
||||
.prepare(
|
||||
`SELECT id, status, total_cents, created_at
|
||||
FROM orders WHERE user_id = ?
|
||||
ORDER BY created_at DESC`
|
||||
)
|
||||
.all(req.session.userId);
|
||||
|
||||
res.render('orders', { title: 'Мои заказы', orders });
|
||||
});
|
||||
|
||||
router.get('/orders/:id', requireAuth, (req, res) => {
|
||||
const order = db
|
||||
.prepare(
|
||||
`SELECT * FROM orders WHERE id = ? AND user_id = ?`
|
||||
)
|
||||
.get(req.params.id, req.session.userId);
|
||||
|
||||
if (!order) {
|
||||
return res.status(404).render('error', {
|
||||
title: 'Не найдено',
|
||||
message: 'Заказ не найден',
|
||||
code: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const items = db
|
||||
.prepare(
|
||||
`SELECT oi.*, p.name, p.slug, p.image_url
|
||||
FROM order_items oi
|
||||
JOIN products p ON p.id = oi.product_id
|
||||
WHERE oi.order_id = ?`
|
||||
)
|
||||
.all(order.id);
|
||||
|
||||
res.render('order', {
|
||||
title: `Заказ #${order.id}`,
|
||||
order,
|
||||
items,
|
||||
success: req.query.success === '1',
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
const { db } = require('./db');
|
||||
|
||||
const count = db.prepare('SELECT COUNT(*) AS n FROM products').get().n;
|
||||
if (count > 0) {
|
||||
console.log('База уже содержит товары, пропуск seed.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const insertCategory = db.prepare(
|
||||
'INSERT INTO categories (slug, name) VALUES (?, ?)'
|
||||
);
|
||||
const insertProduct = db.prepare(`
|
||||
INSERT INTO products (category_id, slug, name, description, price_cents, stock, image_url)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const categories = [
|
||||
{ slug: 'electronics', name: 'Электроника' },
|
||||
{ slug: 'clothing', name: 'Одежда' },
|
||||
{ slug: 'home', name: 'Дом и быт' },
|
||||
];
|
||||
|
||||
const categoryIds = {};
|
||||
const seed = db.transaction(() => {
|
||||
for (const c of categories) {
|
||||
const r = insertCategory.run(c.slug, c.name);
|
||||
categoryIds[c.slug] = r.lastInsertRowid;
|
||||
}
|
||||
|
||||
const products = [
|
||||
{
|
||||
cat: 'electronics',
|
||||
slug: 'wireless-headphones',
|
||||
name: 'Беспроводные наушники',
|
||||
description: 'Шумоподавление, 30 ч автономности, Bluetooth 5.3.',
|
||||
price: 499000,
|
||||
stock: 24,
|
||||
image: 'https://images.unsplash.com/photo-1505740420928-5e560c06d30e?w=400&h=400&fit=crop',
|
||||
},
|
||||
{
|
||||
cat: 'electronics',
|
||||
slug: 'smart-watch',
|
||||
name: 'Умные часы',
|
||||
description: 'Пульс, GPS, водозащита IP68.',
|
||||
price: 1299000,
|
||||
stock: 15,
|
||||
image: 'https://images.unsplash.com/photo-1523275335684-37898b6baf30?w=400&h=400&fit=crop',
|
||||
},
|
||||
{
|
||||
cat: 'electronics',
|
||||
slug: 'mechanical-keyboard',
|
||||
name: 'Механическая клавиатура',
|
||||
description: 'Hot-swap, RGB подсветка, переключатели Brown.',
|
||||
price: 749000,
|
||||
stock: 18,
|
||||
image: 'https://images.unsplash.com/photo-1587829741301-dc798b83add3?w=400&h=400&fit=crop',
|
||||
},
|
||||
{
|
||||
cat: 'clothing',
|
||||
slug: 'cotton-tshirt',
|
||||
name: 'Хлопковая футболка',
|
||||
description: '100% хлопок, унисекс, размеры S–XL.',
|
||||
price: 199000,
|
||||
stock: 50,
|
||||
image: 'https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?w=400&h=400&fit=crop',
|
||||
},
|
||||
{
|
||||
cat: 'clothing',
|
||||
slug: 'denim-jacket',
|
||||
name: 'Джинсовая куртка',
|
||||
description: 'Классический крой, прочный деним.',
|
||||
price: 459000,
|
||||
stock: 12,
|
||||
image: 'https://images.unsplash.com/photo-1551028711-00167b16eac5?w=400&h=400&fit=crop',
|
||||
},
|
||||
{
|
||||
cat: 'home',
|
||||
slug: 'ceramic-mug',
|
||||
name: 'Керамическая кружка',
|
||||
description: 'Объём 350 мл, подходит для посудомойки.',
|
||||
price: 89000,
|
||||
stock: 40,
|
||||
image: 'https://images.unsplash.com/photo-1514228742587-6b1558fcca13?w=400&h=400&fit=crop',
|
||||
},
|
||||
{
|
||||
cat: 'home',
|
||||
slug: 'desk-lamp',
|
||||
name: 'Настольная лампа',
|
||||
description: 'LED, регулировка яркости и цветовой температуры.',
|
||||
price: 329000,
|
||||
stock: 20,
|
||||
image: 'https://images.unsplash.com/photo-1507473885765-e6ed057f782c?w=400&h=400&fit=crop',
|
||||
},
|
||||
{
|
||||
cat: 'home',
|
||||
slug: 'throw-blanket',
|
||||
name: 'Плед',
|
||||
description: 'Мягкий флис, 150×200 см.',
|
||||
price: 249000,
|
||||
stock: 30,
|
||||
image: 'https://images.unsplash.com/photo-1555041469-a586c12e1940?w=400&h=400&fit=crop',
|
||||
},
|
||||
];
|
||||
|
||||
for (const p of products) {
|
||||
insertProduct.run(
|
||||
categoryIds[p.cat],
|
||||
p.slug,
|
||||
p.name,
|
||||
p.description,
|
||||
p.price,
|
||||
p.stock,
|
||||
p.image
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
seed();
|
||||
console.log('Добавлено категорий:', categories.length, ', товаров: 8');
|
||||
@@ -0,0 +1,60 @@
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const session = require('express-session');
|
||||
const SQLiteStore = require('connect-sqlite3')(session);
|
||||
|
||||
require('./db');
|
||||
require('./seed');
|
||||
|
||||
const { loadUser } = require('./middleware/auth');
|
||||
const shopRoutes = require('./routes/shop');
|
||||
const authRoutes = require('./routes/auth');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
app.set('view engine', 'ejs');
|
||||
app.set('views', path.join(__dirname, 'views'));
|
||||
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
app.use(
|
||||
session({
|
||||
store: new SQLiteStore({ db: 'sessions.db', dir: path.join(__dirname, '..', 'data') }),
|
||||
secret: process.env.SESSION_SECRET || 'dev-secret-change-in-production',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
app.use(loadUser);
|
||||
|
||||
app.use('/', shopRoutes);
|
||||
app.use('/', authRoutes);
|
||||
|
||||
app.use((req, res) => {
|
||||
res.status(404).render('error', {
|
||||
title: 'Не найдено',
|
||||
message: 'Страница не найдена',
|
||||
code: 404,
|
||||
});
|
||||
});
|
||||
|
||||
app.use((err, req, res, _next) => {
|
||||
console.error(err);
|
||||
res.status(500).render('error', {
|
||||
title: 'Ошибка',
|
||||
message: 'Внутренняя ошибка сервера',
|
||||
code: 500,
|
||||
});
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Магазин: http://localhost:${PORT}`);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
<%- include('partials/layout-start') %>
|
||||
|
||||
<div class="account">
|
||||
<h1>Личный кабинет</h1>
|
||||
<div class="card account-card">
|
||||
<p><strong><%= user.name %></strong></p>
|
||||
<p class="muted"><%= user.email %></p>
|
||||
<p class="muted">С нами с <%= new Date(user.created_at).toLocaleDateString('ru-RU') %></p>
|
||||
<div class="account-actions">
|
||||
<a href="/orders" class="btn btn--primary">Мои заказы (<%= orderCount %>)</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('partials/layout-end') %>
|
||||
@@ -0,0 +1,54 @@
|
||||
<%- include('partials/layout-start') %>
|
||||
|
||||
<h1>Корзина</h1>
|
||||
|
||||
<% if (error) { %><p class="alert alert--error"><%= error %></p><% } %>
|
||||
|
||||
<% if (!items.length) { %>
|
||||
<p class="empty">Корзина пуста. <a href="/">Перейти в каталог</a></p>
|
||||
<% } else { %>
|
||||
<form action="/cart/update" method="post" class="cart-table-wrap">
|
||||
<table class="cart-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Товар</th>
|
||||
<th>Цена</th>
|
||||
<th>Кол-во</th>
|
||||
<th>Сумма</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% items.forEach(item => { %>
|
||||
<tr>
|
||||
<td class="cart-table__product">
|
||||
<% if (item.image_url) { %>
|
||||
<img src="<%= item.image_url %>" alt="" class="cart-table__thumb">
|
||||
<% } %>
|
||||
<a href="/product/<%= item.slug %>"><%= item.name %></a>
|
||||
</td>
|
||||
<td><%= formatPrice(item.price_cents) %></td>
|
||||
<td>
|
||||
<input type="number" name="items[<%= item.id %>]" value="<%= item.quantity %>" min="0" max="<%= item.stock %>" class="input input--qty">
|
||||
</td>
|
||||
<td><%= formatPrice(item.line_total) %></td>
|
||||
<td>
|
||||
<button type="submit" formaction="/cart/remove/<%= item.id %>" formmethod="post" class="btn btn--ghost btn--sm" title="Удалить">×</button>
|
||||
</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="cart-actions">
|
||||
<button type="submit" class="btn btn--ghost">Обновить</button>
|
||||
<p class="cart-total">Итого: <strong><%= formatPrice(total) %></strong></p>
|
||||
<% if (user) { %>
|
||||
<a href="/checkout" class="btn btn--primary btn--lg">Оформить заказ</a>
|
||||
<% } else { %>
|
||||
<p class="hint"><a href="/login?next=/checkout">Войдите</a>, чтобы оформить заказ.</p>
|
||||
<% } %>
|
||||
</div>
|
||||
</form>
|
||||
<% } %>
|
||||
|
||||
<%- include('partials/layout-end') %>
|
||||
@@ -0,0 +1,43 @@
|
||||
<%- include('partials/layout-start') %>
|
||||
|
||||
<h1>Оформление заказа</h1>
|
||||
|
||||
<% if (error) { %><p class="alert alert--error"><%= error %></p><% } %>
|
||||
|
||||
<div class="checkout-layout">
|
||||
<form action="/checkout" method="post" class="form card">
|
||||
<h2>Данные доставки</h2>
|
||||
<label class="label">
|
||||
Имя
|
||||
<input type="text" name="name" class="input" required value="<%= user ? user.name : '' %>">
|
||||
</label>
|
||||
<label class="label">
|
||||
Email
|
||||
<input type="email" name="email" class="input" required value="<%= user ? user.email : '' %>">
|
||||
</label>
|
||||
<label class="label">
|
||||
Телефон
|
||||
<input type="tel" name="phone" class="input" placeholder="+7 …">
|
||||
</label>
|
||||
<label class="label">
|
||||
Адрес доставки
|
||||
<textarea name="address" class="input" rows="3" required placeholder="Город, улица, дом, квартира"></textarea>
|
||||
</label>
|
||||
<button type="submit" class="btn btn--primary btn--lg btn--block">Подтвердить заказ</button>
|
||||
</form>
|
||||
|
||||
<aside class="checkout-summary card">
|
||||
<h2>Ваш заказ</h2>
|
||||
<ul class="checkout-list">
|
||||
<% items.forEach(item => { %>
|
||||
<li>
|
||||
<span><%= item.name %> × <%= item.quantity %></span>
|
||||
<span><%= formatPrice(item.line_total) %></span>
|
||||
</li>
|
||||
<% }) %>
|
||||
</ul>
|
||||
<p class="checkout-total">Итого: <strong><%= formatPrice(total) %></strong></p>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<%- include('partials/layout-end') %>
|
||||
@@ -0,0 +1,9 @@
|
||||
<%- include('partials/layout-start') %>
|
||||
|
||||
<div class="error-page">
|
||||
<h1><%= code %></h1>
|
||||
<p><%= message %></p>
|
||||
<a href="/" class="btn btn--primary">На главную</a>
|
||||
</div>
|
||||
|
||||
<%- include('partials/layout-end') %>
|
||||
@@ -0,0 +1,47 @@
|
||||
<%- include('partials/layout-start') %>
|
||||
|
||||
<section class="hero">
|
||||
<h1>Каталог товаров</h1>
|
||||
<p>Доставка по России. Оплата при получении.</p>
|
||||
</section>
|
||||
|
||||
<% if (categories.length) { %>
|
||||
<nav class="categories" aria-label="Категории">
|
||||
<a href="/" class="chip <%= !activeCategory ? 'chip--active' : '' %>">Все</a>
|
||||
<% categories.forEach(c => { %>
|
||||
<a href="/?category=<%= c.slug %>" class="chip <%= activeCategory === c.slug ? 'chip--active' : '' %>"><%= c.name %></a>
|
||||
<% }) %>
|
||||
</nav>
|
||||
<% } %>
|
||||
|
||||
<% if (!products.length) { %>
|
||||
<p class="empty">Товары не найдены. Попробуйте другой запрос.</p>
|
||||
<% } else { %>
|
||||
<div class="grid">
|
||||
<% products.forEach(p => { %>
|
||||
<article class="card">
|
||||
<a href="/product/<%= p.slug %>" class="card__image-wrap">
|
||||
<% if (p.image_url) { %>
|
||||
<img src="<%= p.image_url %>" alt="<%= p.name %>" class="card__image" loading="lazy">
|
||||
<% } else { %>
|
||||
<div class="card__placeholder">Нет фото</div>
|
||||
<% } %>
|
||||
</a>
|
||||
<div class="card__body">
|
||||
<% if (p.category_name) { %>
|
||||
<span class="card__category"><%= p.category_name %></span>
|
||||
<% } %>
|
||||
<h2 class="card__title"><a href="/product/<%= p.slug %>"><%= p.name %></a></h2>
|
||||
<p class="card__price"><%= formatPrice(p.price_cents) %></p>
|
||||
<form action="/cart/add" method="post" class="card__form">
|
||||
<input type="hidden" name="product_id" value="<%= p.id %>">
|
||||
<input type="hidden" name="redirect" value="/cart">
|
||||
<button type="submit" class="btn btn--primary btn--block">В корзину</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
<% }) %>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<%- include('partials/layout-end') %>
|
||||
@@ -0,0 +1,21 @@
|
||||
<%- include('partials/layout-start') %>
|
||||
|
||||
<div class="auth">
|
||||
<form action="/login" method="post" class="form card">
|
||||
<h1>Вход</h1>
|
||||
<% if (error) { %><p class="alert alert--error"><%= error %></p><% } %>
|
||||
<input type="hidden" name="next" value="<%= next %>">
|
||||
<label class="label">
|
||||
Email
|
||||
<input type="email" name="email" class="input" required value="<%= values.email || '' %>">
|
||||
</label>
|
||||
<label class="label">
|
||||
Пароль
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<%- include('partials/layout-end') %>
|
||||
@@ -0,0 +1,30 @@
|
||||
<%- include('partials/layout-start') %>
|
||||
|
||||
<h1>Заказ #<%= order.id %></h1>
|
||||
|
||||
<% if (success) { %>
|
||||
<p class="alert alert--success">Заказ успешно оформлен! Мы свяжемся с вами по email.</p>
|
||||
<% } %>
|
||||
|
||||
<div class="card order-card">
|
||||
<% const statusLabels = { pending: 'Ожидает обработки', paid: 'Оплачен', shipped: 'Отправлен', cancelled: 'Отменён' }; %>
|
||||
<p><strong>Статус:</strong> <span class="status status--<%= order.status %>"><%= statusLabels[order.status] || order.status %></span></p>
|
||||
<p><strong>Дата:</strong> <%= new Date(order.created_at).toLocaleString('ru-RU') %></p>
|
||||
<p><strong>Доставка:</strong> <%= order.address %></p>
|
||||
<p><strong>Контакт:</strong> <%= order.customer_name %>, <%= order.customer_email %><% if (order.customer_phone) { %>, <%= order.customer_phone %><% } %></p>
|
||||
|
||||
<h2>Состав заказа</h2>
|
||||
<ul class="checkout-list">
|
||||
<% items.forEach(item => { %>
|
||||
<li>
|
||||
<span><a href="/product/<%= item.slug %>"><%= item.name %></a> × <%= item.quantity %></span>
|
||||
<span><%= formatPrice(item.price_cents * item.quantity) %></span>
|
||||
</li>
|
||||
<% }) %>
|
||||
</ul>
|
||||
<p class="checkout-total">Итого: <strong><%= formatPrice(order.total_cents) %></strong></p>
|
||||
</div>
|
||||
|
||||
<p><a href="/orders" class="link-back">← Все заказы</a></p>
|
||||
|
||||
<%- include('partials/layout-end') %>
|
||||
@@ -0,0 +1,33 @@
|
||||
<%- include('partials/layout-start') %>
|
||||
|
||||
<h1>Мои заказы</h1>
|
||||
|
||||
<% if (!orders.length) { %>
|
||||
<p class="empty">Заказов пока нет. <a href="/">Перейти в каталог</a></p>
|
||||
<% } else { %>
|
||||
<table class="cart-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>№</th>
|
||||
<th>Дата</th>
|
||||
<th>Статус</th>
|
||||
<th>Сумма</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% const statusLabels = { pending: 'Ожидает', paid: 'Оплачен', shipped: 'Отправлен', cancelled: 'Отменён' }; %>
|
||||
<% orders.forEach(o => { %>
|
||||
<tr>
|
||||
<td>#<%= o.id %></td>
|
||||
<td><%= new Date(o.created_at).toLocaleString('ru-RU') %></td>
|
||||
<td><span class="status status--<%= o.status %>"><%= statusLabels[o.status] || o.status %></span></td>
|
||||
<td><%= formatPrice(o.total_cents) %></td>
|
||||
<td><a href="/orders/<%= o.id %>">Подробнее</a></td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% } %>
|
||||
|
||||
<%- include('partials/layout-end') %>
|
||||
@@ -0,0 +1,8 @@
|
||||
</main>
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<p>© <%= new Date().getFullYear() %> Shop — локальный интернет-магазин на Node.js + SQLite</p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,37 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><%= title %> — Shop</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<div class="container header__inner">
|
||||
<a href="/" class="logo">Shop</a>
|
||||
<form class="search" action="/" method="get">
|
||||
<input type="search" name="q" placeholder="Поиск товаров…" value="<%= typeof searchQuery !== 'undefined' ? searchQuery : '' %>" aria-label="Поиск">
|
||||
<button type="submit" class="btn btn--ghost">Найти</button>
|
||||
</form>
|
||||
<nav class="nav">
|
||||
<a href="/cart" class="nav__link nav__cart">
|
||||
Корзина
|
||||
<% if (cartCount > 0) { %><span class="badge"><%= cartCount %></span><% } %>
|
||||
</a>
|
||||
<% if (user) { %>
|
||||
<a href="/account" class="nav__link"><%= user.name %></a>
|
||||
<form action="/logout" method="post" class="inline-form">
|
||||
<button type="submit" class="btn btn--ghost btn--sm">Выйти</button>
|
||||
</form>
|
||||
<% } else { %>
|
||||
<a href="/login" class="nav__link">Вход</a>
|
||||
<a href="/register" class="btn btn--primary btn--sm">Регистрация</a>
|
||||
<% } %>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main class="main container">
|
||||
@@ -0,0 +1,37 @@
|
||||
<%- include('partials/layout-start') %>
|
||||
|
||||
<article class="product-detail">
|
||||
<div class="product-detail__media">
|
||||
<% if (product.image_url) { %>
|
||||
<img src="<%= product.image_url %>" alt="<%= product.name %>" class="product-detail__image">
|
||||
<% } else { %>
|
||||
<div class="card__placeholder product-detail__image">Нет фото</div>
|
||||
<% } %>
|
||||
</div>
|
||||
<div class="product-detail__info">
|
||||
<% if (product.category_name) { %>
|
||||
<a href="/?category=<%= product.category_slug %>" class="card__category"><%= product.category_name %></a>
|
||||
<% } %>
|
||||
<h1><%= product.name %></h1>
|
||||
<p class="product-detail__price"><%= formatPrice(product.price_cents) %></p>
|
||||
<p class="product-detail__desc"><%= product.description %></p>
|
||||
<p class="product-detail__stock">В наличии: <strong><%= product.stock %></strong> шт.</p>
|
||||
|
||||
<% if (product.stock > 0) { %>
|
||||
<form action="/cart/add" method="post" class="product-detail__form">
|
||||
<input type="hidden" name="product_id" value="<%= product.id %>">
|
||||
<label class="label">
|
||||
Количество
|
||||
<input type="number" name="quantity" value="1" min="1" max="<%= product.stock %>" class="input input--qty">
|
||||
</label>
|
||||
<input type="hidden" name="redirect" value="/cart">
|
||||
<button type="submit" class="btn btn--primary btn--lg">Добавить в корзину</button>
|
||||
</form>
|
||||
<% } else { %>
|
||||
<p class="alert alert--warn">Нет в наличии</p>
|
||||
<% } %>
|
||||
<a href="/" class="link-back">← Назад в каталог</a>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<%- include('partials/layout-end') %>
|
||||
@@ -0,0 +1,28 @@
|
||||
<%- include('partials/layout-start') %>
|
||||
|
||||
<div class="auth">
|
||||
<form action="/register" method="post" class="form card">
|
||||
<h1>Регистрация</h1>
|
||||
<% if (error) { %><p class="alert alert--error"><%= error %></p><% } %>
|
||||
<label class="label">
|
||||
Имя
|
||||
<input type="text" name="name" class="input" required value="<%= values.name || '' %>">
|
||||
</label>
|
||||
<label class="label">
|
||||
Email
|
||||
<input type="email" name="email" class="input" required value="<%= values.email || '' %>">
|
||||
</label>
|
||||
<label class="label">
|
||||
Пароль
|
||||
<input type="password" name="password" class="input" required minlength="6">
|
||||
</label>
|
||||
<label class="label">
|
||||
Повторите пароль
|
||||
<input type="password" name="password2" class="input" required>
|
||||
</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') %>
|
||||
Reference in New Issue
Block a user