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