diff --git a/modules/database/index.js b/modules/database/index.js
index 1749061..f1539b2 100644
--- a/modules/database/index.js
+++ b/modules/database/index.js
@@ -331,6 +331,26 @@ function initDatabase(db) {
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`, () => {
+ setupVisitorLogTable();
+ });
+ }
+
+ function setupVisitorLogTable() {
+ db.run(`CREATE TABLE IF NOT EXISTS visitor_log (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ ip TEXT NOT NULL,
+ session_id TEXT,
+ user_agent TEXT,
+ accept_language TEXT,
+ country TEXT,
+ country_code TEXT,
+ page_path TEXT,
+ visit_date DATE DEFAULT (date('now')),
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )`, () => {
+ db.run(`CREATE INDEX IF NOT EXISTS idx_visitor_log_session ON visitor_log(session_id)`);
+ db.run(`CREATE INDEX IF NOT EXISTS idx_visitor_log_date ON visitor_log(visit_date)`);
+ db.run(`CREATE INDEX IF NOT EXISTS idx_visitor_log_country ON visitor_log(country_code)`);
setupIndices();
});
}
diff --git a/modules/visitors/index.js b/modules/visitors/index.js
new file mode 100644
index 0000000..5e6a813
--- /dev/null
+++ b/modules/visitors/index.js
@@ -0,0 +1,145 @@
+let db;
+
+function init(database) {
+ db = database;
+}
+
+function getStats(req, res) {
+ db.get(`SELECT COUNT(DISTINCT session_id) as total FROM visitor_log`, [], (err, totalRow) => {
+ if (err) return res.status(500).json({ error: err.message });
+ db.get(`SELECT COUNT(DISTINCT session_id) as today FROM visitor_log WHERE visit_date = date('now')`, [], (err, todayRow) => {
+ if (err) return res.status(500).json({ error: err.message });
+ db.get(`SELECT COUNT(DISTINCT session_id) as week FROM visitor_log WHERE visit_date >= date('now', '-6 days')`, [], (err, weekRow) => {
+ if (err) return res.status(500).json({ error: err.message });
+ db.get(`SELECT COUNT(DISTINCT session_id) as month FROM visitor_log WHERE visit_date >= date('now', 'start of month')`, [], (err, monthRow) => {
+ if (err) return res.status(500).json({ error: err.message });
+ db.get(`SELECT COUNT(*) as page_views FROM visitor_log`, [], (err, pvRow) => {
+ if (err) return res.status(500).json({ error: err.message });
+ db.get(`SELECT COUNT(DISTINCT ip) as unique_ips FROM visitor_log`, [], (err, ipRow) => {
+ if (err) return res.status(500).json({ error: err.message });
+ res.json({
+ total: totalRow ? totalRow.total : 0,
+ today: todayRow ? todayRow.today : 0,
+ week: weekRow ? weekRow.week : 0,
+ month: monthRow ? monthRow.month : 0,
+ pageViews: pvRow ? pvRow.page_views : 0,
+ uniqueIps: ipRow ? ipRow.unique_ips : 0
+ });
+ });
+ });
+ });
+ });
+ });
+ });
+}
+
+function getCountries(req, res) {
+ db.all(`SELECT COALESCE(NULLIF(country, ''), 'Другое') as country,
+ COALESCE(NULLIF(country_code, ''), '--') as country_code,
+ COUNT(DISTINCT session_id) as count
+ FROM visitor_log
+ GROUP BY COALESCE(NULLIF(country_code, ''), '--')
+ ORDER BY count DESC LIMIT 30`, [], (err, rows) => {
+ if (err) return res.status(500).json({ error: err.message });
+ res.json(rows || []);
+ });
+}
+
+function getLanguages(req, res) {
+ db.all(`SELECT accept_language, COUNT(DISTINCT session_id) as count
+ FROM visitor_log
+ WHERE accept_language IS NOT NULL AND accept_language != ''
+ GROUP BY accept_language
+ ORDER BY count DESC`, [], (err, rows) => {
+ if (err) return res.status(500).json({ error: err.message });
+ const parsed = (rows || []).map(r => {
+ const primary = (r.accept_language || '').split(',')[0].split(';')[0].trim();
+ const lang = primary.split('-')[0];
+ return { lang, raw: primary, count: r.count };
+ });
+ res.json(parsed);
+ });
+}
+
+function getDaily(req, res) {
+ db.all(`SELECT visit_date, COUNT(DISTINCT session_id) as count, COUNT(*) as page_views
+ FROM visitor_log
+ WHERE visit_date >= date('now', '-29 days')
+ GROUP BY visit_date
+ ORDER BY visit_date ASC`, [], (err, rows) => {
+ if (err) return res.status(500).json({ error: err.message });
+ res.json(rows || []);
+ });
+}
+
+function getIPs(req, res) {
+ const limit = Math.min(parseInt(req.query.limit) || 50, 200);
+ const offset = parseInt(req.query.offset) || 0;
+ const { id, ip, country, lang, date_from, date_to, page } = req.query;
+
+ const whereClauses = [];
+ const params = [];
+
+ if (id) { whereClauses.push('id = ?'); params.push(parseInt(id)); }
+ if (ip) { whereClauses.push('ip LIKE ?'); params.push('%' + ip + '%'); }
+ if (country) { whereClauses.push('(country LIKE ? OR country_code LIKE ?)'); params.push('%' + country + '%'); params.push('%' + country + '%'); }
+ if (lang) { whereClauses.push('accept_language LIKE ?'); params.push('%' + lang + '%'); }
+ if (date_from) { whereClauses.push('visit_date >= ?'); params.push(date_from); }
+ if (date_to) { whereClauses.push('visit_date <= ?'); params.push(date_to); }
+ if (page) { whereClauses.push('page_path LIKE ?'); params.push('%' + page + '%'); }
+
+ const whereSQL = whereClauses.length > 0 ? 'WHERE ' + whereClauses.join(' AND ') : '';
+
+ db.get(`SELECT COUNT(*) as total FROM visitor_log ${whereSQL}`, params, (err, countRow) => {
+ if (err) return res.status(500).json({ error: err.message });
+
+ const allParams = [...params, limit, offset];
+ db.all(`SELECT id, ip, COALESCE(NULLIF(country, ''), 'Другое') as country,
+ country_code, session_id, visit_date, page_path,
+ COALESCE(NULLIF(accept_language, ''), '-') as accept_language,
+ created_at
+ FROM visitor_log
+ ${whereSQL}
+ ORDER BY created_at DESC
+ LIMIT ? OFFSET ?`, allParams, (err, rows) => {
+ if (err) return res.status(500).json({ error: err.message });
+ res.json({
+ entries: rows || [],
+ total: countRow ? countRow.total : 0,
+ limit,
+ offset
+ });
+ });
+ });
+}
+
+function getFilterOptions(req, res) {
+ db.all(`SELECT DISTINCT COALESCE(NULLIF(country, ''), 'Другое') as country
+ FROM visitor_log ORDER BY country`, [], (err, countries) => {
+ if (err) return res.status(500).json({ error: err.message });
+ db.all(`SELECT DISTINCT accept_language FROM visitor_log
+ WHERE accept_language IS NOT NULL AND accept_language != ''
+ ORDER BY accept_language`, [], (err, langs) => {
+ if (err) return res.status(500).json({ error: err.message });
+ db.all(`SELECT DISTINCT page_path FROM visitor_log ORDER BY page_path`, [], (err, pages) => {
+ if (err) return res.status(500).json({ error: err.message });
+ res.json({
+ countries: (countries || []).map(c => c.country),
+ languages: (langs || []).map(l => l.accept_language),
+ pages: (pages || []).map(p => p.page_path)
+ });
+ });
+ });
+ });
+}
+
+function setupRoutes(app, authenticateToken) {
+ app.get('/api/admin/visitors/stats', authenticateToken, getStats);
+ app.get('/api/admin/visitors/countries', authenticateToken, getCountries);
+ app.get('/api/admin/visitors/languages', authenticateToken, getLanguages);
+ app.get('/api/admin/visitors/daily', authenticateToken, getDaily);
+ app.get('/api/admin/visitors/ips', authenticateToken, getIPs);
+ app.get('/api/admin/visitors/filters', authenticateToken, getFilterOptions);
+}
+
+module.exports = { init, setupRoutes };
diff --git a/package.json b/package.json
index 3ba4ecb..330f400 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,7 @@
"bcryptjs": "^3.0.3",
"dotenv": "^17.4.2",
"express": "^5.2.1",
+ "geoip-lite": "^2.0.3",
"jsonwebtoken": "^9.0.3",
"multer": "^1.4.5-lts.1",
"node-cron": "^4.2.1",
diff --git a/public/admin.html b/public/admin.html
index bca3041..08e8416 100644
--- a/public/admin.html
+++ b/public/admin.html
@@ -208,6 +208,7 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
Сезонные цены
Развлечения
Главный экран
+ Посетители
Отзывы
Настройки
Профиль
@@ -724,7 +725,6 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
-
Главный экран
@@ -736,6 +736,85 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
+
+
+
+
Посетители
+
+
+
+
+
+
Всего посетителей
+
-
+
+
+
+
+
+
Просмотров страниц
+
-
+
+
+
+
+
+
+
+
+
+
+
Посещения за 30 дней
+
+
+
+
+
+
+
+
+
IP-адреса и даты
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -911,6 +990,7 @@ function initTabs() {
if (tab === 'seasonal') loadSeasonalPrices();
if (tab === 'activities') loadActivitiesAdmin();
if (tab === 'hero') loadHeroMediaAdmin();
+ if (tab === 'visitors') loadVisitors();
if (tab === 'reviews') loadReviews();
if (tab === 'settings') loadSettings();
if (tab === 'profile') loadProfile();
@@ -2976,6 +3056,135 @@ function deleteHeroMedia(id) {
.then(() => { loadHeroMediaAdmin(); showToast('Медиа удалено'); })
.catch(err => showToast(err.message, 'error'));
}
+
+async function loadVisitors() {
+ try {
+ const stats = await api('/api/admin/visitors/stats');
+ document.getElementById('visTotal').textContent = stats.total;
+ document.getElementById('visToday').textContent = stats.today;
+ document.getElementById('visWeek').textContent = stats.week;
+ document.getElementById('visMonth').textContent = stats.month;
+ document.getElementById('visPageViews').textContent = stats.pageViews;
+ document.getElementById('visUniqueIps').textContent = stats.uniqueIps;
+
+ const countries = await api('/api/admin/visitors/countries');
+ const ct = document.getElementById('visCountriesTable');
+ if (countries.length === 0) {
+ ct.innerHTML = 'Нет данных
';
+ } else {
+ ct.innerHTML = '| ��трана | ��осетителей |
' +
+ countries.map(c => `| ${c.country || '—'} ${c.country_code || ''} | ${c.count} |
`).join('') +
+ '
';
+ }
+
+ const langs = await api('/api/admin/visitors/languages');
+ const lt = document.getElementById('visLanguagesTable');
+ if (langs.length === 0) {
+ lt.innerHTML = 'Нет данных
';
+ } else {
+ lt.innerHTML = '| Язык | Посетителей |
' +
+ langs.map(l => `| ${l.raw || l.lang || '—'} | ${l.count} |
`).join('') +
+ '
';
+ }
+
+ const daily = await api('/api/admin/visitors/daily');
+ if (daily.length > 0) {
+ let html = '| Дата | Посетителей | Просмотров |
';
+ daily.reverse().forEach(d => {
+ html += `| ${d.visit_date} | ${d.count} | ${d.page_views} |
`;
+ });
+ html += '
';
+ document.getElementById('visDailyChart').innerHTML = html;
+ }
+
+ await loadFilterOptions();
+ loadIPs(0);
+ } catch (err) {
+ showToast(err.message, 'error');
+ }
+}
+
+function fillSelect(id, items) {
+ const sel = document.getElementById(id);
+ if (!sel) return;
+ const val = sel.value;
+ sel.innerHTML = '';
+ items.forEach(item => {
+ const opt = document.createElement('option');
+ opt.value = item;
+ opt.textContent = item;
+ sel.appendChild(opt);
+ });
+ if (val) sel.value = val;
+}
+
+async function loadFilterOptions() {
+ try {
+ const data = await api('/api/admin/visitors/filters');
+ fillSelect('visFiltCountry', data.countries || []);
+ fillSelect('visFiltLang', data.languages || []);
+ fillSelect('visFiltPage', data.pages || []);
+ } catch (e) {
+ // silently fail, filters stay as "Все"
+ }
+}
+
+async function loadIPs(offset) {
+ const limit = 50;
+ document.getElementById('visIPsTable').innerHTML = 'Загрузка...
';
+ try {
+ const filters = {
+ id: document.getElementById('visFiltId').value.trim(),
+ ip: document.getElementById('visFiltIp').value.trim(),
+ country: document.getElementById('visFiltCountry').value.trim(),
+ lang: document.getElementById('visFiltLang').value.trim(),
+ date_from: document.getElementById('visFiltDateFrom').value,
+ date_to: document.getElementById('visFiltDateTo').value,
+ page: document.getElementById('visFiltPage').value.trim()
+ };
+ let qs = `limit=${limit}&offset=${offset}`;
+ Object.keys(filters).forEach(k => {
+ if (filters[k]) qs += '&' + k + '=' + encodeURIComponent(filters[k]);
+ });
+ const data = await api('/api/admin/visitors/ips?' + qs);
+ if (data.entries.length === 0) {
+ document.getElementById('visIPsTable').innerHTML = 'Нет данных
';
+ document.getElementById('visIPsPagination').innerHTML = '';
+ return;
+ }
+ let html = '' +
+ '| IP | ' +
+ 'Страна | ' +
+ 'Язык | ' +
+ 'Дата | ' +
+ 'Страница | ' +
+ '
';
+ data.entries.forEach(e => {
+ html += `` +
+ `| ${e.ip} | ` +
+ `${e.country || '-'} ${e.country_code || ''} | ` +
+ `${e.accept_language || '-'} | ` +
+ `${e.created_at ? e.created_at.substring(0, 16) : e.visit_date} | ` +
+ `${e.page_path} | ` +
+ `
`;
+ });
+ html += '
';
+ document.getElementById('visIPsTable').innerHTML = html;
+
+ const totalPages = Math.ceil(data.total / limit);
+ const currentPage = Math.floor(offset / limit) + 1;
+ let paginationHtml = `Строк: ${data.total} · Стр. ${currentPage} из ${totalPages} `;
+ if (offset > 0) {
+ paginationHtml += ``;
+ }
+ if (offset + limit < data.total) {
+ paginationHtml += ``;
+ }
+ document.getElementById('visIPsPagination').innerHTML = paginationHtml;
+ } catch (err) {
+ document.getElementById('visIPsTable').innerHTML = 'Ошибка загрузки
';
+ }
+}
diff --git a/server.js b/server.js
index 30b9114..30b139c 100644
--- a/server.js
+++ b/server.js
@@ -188,6 +188,66 @@ app.use((req, res, next) => {
if (req.method === 'OPTIONS') return res.sendStatus(200);
next();
});
+
+const geoip = require('geoip-lite');
+const ipCountryCache = {};
+
+function getCountryByIp(ip) {
+ if (!ip || ip === '127.0.0.1' || ip === '::1' || ip.startsWith('::ffff:127.')) return null;
+ if (ipCountryCache[ip] !== undefined) return ipCountryCache[ip];
+ try {
+ const geo = geoip.lookup(ip);
+ const result = geo ? { country: geo.country, code: geo.country } : null;
+ ipCountryCache[ip] = result;
+ return result;
+ } catch (e) {
+ ipCountryCache[ip] = null;
+ return null;
+ }
+}
+
+function parseCookies(header) {
+ const cookies = {};
+ if (!header) return cookies;
+ header.split(';').forEach(c => {
+ const idx = c.indexOf('=');
+ if (idx > 0) cookies[c.substring(0, idx).trim()] = c.substring(idx + 1).trim();
+ });
+ return cookies;
+}
+
+app.use((req, res, next) => {
+ if (req.method !== 'GET' || req.path.startsWith('/api/') ||
+ req.path.startsWith('/css/') || req.path.startsWith('/js/') ||
+ req.path.startsWith('/img/') || req.path.startsWith('/data/') ||
+ req.path.startsWith('/uploads/') || req.path.startsWith('/webfonts/') ||
+ req.path === '/metrics' || req.path === '/favicon.ico') {
+ return next();
+ }
+
+ const ip = req.ip || req.connection.remoteAddress || '';
+ const cookies = parseCookies(req.headers.cookie);
+ let sessionId = cookies.visitor_sid;
+
+ if (!sessionId) {
+ sessionId = require('crypto').randomUUID();
+ res.setHeader('Set-Cookie', `visitor_sid=${sessionId}; Path=/; Max-Age=86400; SameSite=Lax`);
+ }
+
+ const geo = getCountryByIp(ip);
+
+ db.run(
+ `INSERT INTO visitor_log (ip, session_id, user_agent, accept_language, country, country_code, page_path, visit_date)
+ VALUES (?, ?, ?, ?, ?, ?, ?, date('now'))`,
+ [ip, sessionId, (req.headers['user-agent'] || '').substring(0, 500),
+ (req.headers['accept-language'] || '').substring(0, 200),
+ geo ? geo.country : null, geo ? geo.code : null, req.path],
+ (err) => { if (err) console.error('Visitor log error:', err.message); }
+ );
+
+ next();
+});
+
app.use(express.static(path.join(__dirname, 'public')));
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
app.use('/data/room_images', express.static(roomsUploadsDir));
@@ -272,6 +332,7 @@ const syncModule = require('./modules/sync');
const reportsModule = require('./modules/reports');
const activitiesModule = require('./modules/activities');
const heroModule = require('./modules/hero');
+const visitorsModule = require('./modules/visitors');
const { runStartupTests } = require('./tests/runStartupTests');
modules.auth = authModule;
@@ -291,6 +352,7 @@ modules.sync = syncModule;
modules.reports = reportsModule;
modules.activities = activitiesModule;
modules.hero = heroModule;
+modules.visitors = visitorsModule;
authModule.init(db, JWT_SECRET);
bookingsModule.init(db);
@@ -308,6 +370,7 @@ syncModule.init(db, settingsModule, emailModule, telegramModule);
reportsModule.init(db);
activitiesModule.init(db);
heroModule.init(db);
+visitorsModule.init(db);
function initDefaultRooms() {
db.get("SELECT COUNT(*) as count FROM rooms", (err, row) => {
@@ -382,6 +445,7 @@ telegramModule.setupRoutes(app, authModule.authenticateToken, authModule.require
reportsModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin);
activitiesModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin, uploadActivity);
heroModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin, uploadHeroImage, uploadHeroVideo);
+visitorsModule.setupRoutes(app, authModule.authenticateToken);
app.get('/api/translations/:lang', (req, res) => {
const lang = req.params.lang;