diff --git a/backend/pb_migrations/1777809787_created_site_visitors.js b/backend/pb_migrations/1777809787_created_site_visitors.js
new file mode 100644
index 0000000..3e5cd05
--- /dev/null
+++ b/backend/pb_migrations/1777809787_created_site_visitors.js
@@ -0,0 +1,103 @@
+///
+migrate((app) => {
+ const collection = new Collection({
+ "createRule": null,
+ "deleteRule": null,
+ "fields": [
+ {
+ "autogeneratePattern": "[a-z0-9]{15}",
+ "help": "",
+ "hidden": false,
+ "id": "text3208210256",
+ "max": 15,
+ "min": 15,
+ "name": "id",
+ "pattern": "^[a-z0-9]+$",
+ "presentable": false,
+ "primaryKey": true,
+ "required": true,
+ "system": true,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "help": "",
+ "hidden": false,
+ "id": "text791980464",
+ "max": 0,
+ "min": 0,
+ "name": "visitor_hash",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "help": "",
+ "hidden": false,
+ "id": "text2783163181",
+ "max": 0,
+ "min": 0,
+ "name": "ip",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "help": "",
+ "hidden": false,
+ "id": "text3293145029",
+ "max": 0,
+ "min": 0,
+ "name": "user_agent",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "hidden": false,
+ "id": "autodate2990389176",
+ "name": "created",
+ "onCreate": true,
+ "onUpdate": false,
+ "presentable": false,
+ "system": false,
+ "type": "autodate"
+ },
+ {
+ "hidden": false,
+ "id": "autodate3332085495",
+ "name": "updated",
+ "onCreate": true,
+ "onUpdate": true,
+ "presentable": false,
+ "system": false,
+ "type": "autodate"
+ }
+ ],
+ "id": "pbc_2651661972",
+ "indexes": [],
+ "listRule": null,
+ "name": "site_visitors",
+ "system": false,
+ "type": "base",
+ "updateRule": null,
+ "viewRule": null
+ });
+
+ return app.save(collection);
+}, (app) => {
+ const collection = app.findCollectionByNameOrId("pbc_2651661972");
+
+ return app.delete(collection);
+})
diff --git a/backend/pb_migrations/1777809835_updated_site_visitors.js b/backend/pb_migrations/1777809835_updated_site_visitors.js
new file mode 100644
index 0000000..99574c5
--- /dev/null
+++ b/backend/pb_migrations/1777809835_updated_site_visitors.js
@@ -0,0 +1,28 @@
+///
+migrate((app) => {
+ const collection = app.findCollectionByNameOrId("pbc_2651661972")
+
+ // update collection data
+ unmarshal({
+ "createRule": "",
+ "deleteRule": "",
+ "listRule": "",
+ "updateRule": "",
+ "viewRule": ""
+ }, collection)
+
+ return app.save(collection)
+}, (app) => {
+ const collection = app.findCollectionByNameOrId("pbc_2651661972")
+
+ // update collection data
+ unmarshal({
+ "createRule": null,
+ "deleteRule": null,
+ "listRule": null,
+ "updateRule": null,
+ "viewRule": null
+ }, collection)
+
+ return app.save(collection)
+})
diff --git a/bun.lockb b/bun.lockb
new file mode 100644
index 0000000..393c51d
Binary files /dev/null and b/bun.lockb differ
diff --git a/frontend/src/components/base/VisitorCounter.astro b/frontend/src/components/base/VisitorCounter.astro
new file mode 100644
index 0000000..a9af2f2
--- /dev/null
+++ b/frontend/src/components/base/VisitorCounter.astro
@@ -0,0 +1,77 @@
+---
+import { Icon } from 'astro-icon/components';
+
+interface Props {
+ today?: number;
+ total?: number;
+}
+
+const { today = 0, total = 0 } = Astro.props;
+---
+
+
+
+
+ {today} сегодня
+
+
+
+ {total} всего
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/layouts/footer/Footer.astro b/frontend/src/components/layouts/footer/Footer.astro
index d10a3f8..8b275c1 100644
--- a/frontend/src/components/layouts/footer/Footer.astro
+++ b/frontend/src/components/layouts/footer/Footer.astro
@@ -1,6 +1,7 @@
---
import { CONTACT_CONSTANTS } from "@constants/constants.ts";
import SocialIcons from "@components/base/SocialIcons.astro";
+import VisitorCounter from "@components/base/VisitorCounter.astro";
const currentYear = new Date().getFullYear();
@@ -147,11 +148,14 @@ const menu = [
-
- © {currentYear} ADVOKAT086. Все права защищены.
-
+
+
+ © {currentYear} ADVOKAT086. Все права защищены.
+
+
+
-
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/icons/users-total.svg b/frontend/src/icons/users-total.svg
new file mode 100644
index 0000000..19320c8
--- /dev/null
+++ b/frontend/src/icons/users-total.svg
@@ -0,0 +1,6 @@
+
\ No newline at end of file
diff --git a/frontend/src/pages/api/visitors.ts b/frontend/src/pages/api/visitors.ts
new file mode 100644
index 0000000..7aea6f2
--- /dev/null
+++ b/frontend/src/pages/api/visitors.ts
@@ -0,0 +1,117 @@
+import type { APIRoute } from 'astro';
+import crypto from 'crypto';
+
+const POCKETBASE_URL = import.meta.env.POCKETBASE_URL || 'http://localhost:8090';
+
+function getClientIp(request: Request): string {
+ const forwarded = request.headers.get('x-forwarded-for');
+ if (forwarded) {
+ return forwarded.split(',')[0].trim();
+ }
+ const realIp = request.headers.get('x-real-ip');
+ if (realIp) {
+ return realIp;
+ }
+ const host = request.headers.get('host') || 'localhost';
+ return host.includes('localhost') || host.includes('127.0.0.1') ? 'localhost' : 'unknown';
+}
+
+function generateVisitorHash(ip: string, userAgent: string): string {
+ const stableIP = ip.split(':').pop() || ip;
+ const uaParts = userAgent.split(' ');
+ const stableUA = uaParts.slice(0, 2).join(' ');
+ return crypto.createHash('sha256').update(stableIP + stableUA).digest('hex').slice(0, 32);
+}
+
+async function pbRequest(method: string, path: string, body?: object) {
+ const url = `${POCKETBASE_URL}${path}`;
+ const options: RequestInit = {
+ method,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Origin': 'http://127.0.0.1:4321',
+ },
+ };
+ if (body) options.body = JSON.stringify(body);
+
+ const res = await fetch(url, options);
+ if (!res.ok) {
+ const err = await res.text();
+ throw new Error(`PB ${method} ${path}: ${res.status} - ${err}`);
+ }
+ return res.json();
+}
+
+function jsonResponse(data: object, status = 200): Response {
+ return new Response(JSON.stringify(data), {
+ status,
+ headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }
+ });
+}
+
+export const GET: APIRoute = async ({ request }) => {
+ try {
+ const url = new URL(request.url);
+ const isRepeatVisit = url.searchParams.get('repeat') === 'true';
+
+ const ip = getClientIp(request);
+ const userAgent = request.headers.get('user-agent') || 'unknown';
+ const visitorHash = generateVisitorHash(ip, userAgent);
+
+ const now = new Date();
+ const todayStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0, 0));
+ const todayStartStr = todayStart.toISOString().replace('T', ' ').replace('Z', '');
+
+ const filterTodayVisitor = `visitor_hash="${visitorHash}" && created >= "${todayStartStr}"`;
+
+ let existingVisitor;
+ try {
+ const res = await pbRequest('GET', `/api/collections/site_visitors/records?filter=${encodeURIComponent(filterTodayVisitor)}&perPage=1`);
+ existingVisitor = { totalItems: res.totalItems || 0 };
+ } catch {
+ existingVisitor = { totalItems: 0 };
+ }
+
+ let isNewVisitor = false;
+
+ if (isRepeatVisit) {
+ isNewVisitor = false;
+ }
+ else if (existingVisitor.totalItems === 0) {
+ isNewVisitor = true;
+ try {
+ await pbRequest('POST', '/api/collections/site_visitors/records', {
+ visitor_hash: visitorHash,
+ ip: ip,
+ user_agent: userAgent,
+ });
+ } catch {
+ // ignore
+ }
+ }
+
+ const filterToday = `created >= "${todayStartStr}"`;
+ let todayCount = 0;
+ let totalCount = 0;
+
+ try {
+ const resToday = await pbRequest('GET', `/api/collections/site_visitors/records?filter=${encodeURIComponent(filterToday)}&perPage=1`);
+ todayCount = resToday.totalItems || 0;
+ } catch {
+ todayCount = 0;
+ }
+
+ try {
+ const resTotal = await pbRequest('GET', `/api/collections/site_visitors/records?perPage=1`);
+ totalCount = resTotal.totalItems || 0;
+ } catch {
+ totalCount = 0;
+ }
+
+ return jsonResponse({ todayVisitors: todayCount, totalVisitors: totalCount, isNewVisitor }, 200);
+
+ } catch (error) {
+ console.error('[Visitors] Error:', error);
+ return jsonResponse({ error: 'Внутренняя ошибка сервера' }, 500);
+ }
+};
\ No newline at end of file
diff --git a/package.json b/package.json
index 84e8486..45c69c1 100644
--- a/package.json
+++ b/package.json
@@ -21,5 +21,8 @@
"concurrently": "^9.2.1",
"maildev": "^2.2.1"
},
- "private": true
+ "private": true,
+ "dependencies": {
+ "astro-icon": "^1.1.5"
+ }
}
\ No newline at end of file