feat: скаффолд Astro 5 SSG (главная + /privacy + consent gate)
Some checks failed
deploy / deploy (push) Failing after 14s

- Главная: hero, адрес Люблинская 100 (Аквапарк ФЭНТАЗИ), 4 кликабельных tel:, карта Яндекса
- /privacy: политика 152-ФЗ + ConsentRevoke (отозвать/сбросить)
- Аналитика перенесена 1:1 с WP: Яндекс.Метрика 47169531 (Webvisor) + GA4 GT-WRF7ZZ8
- Скрипты в type=text/plain, активируются после согласия (pit-consent в localStorage+cookie)
- robots.txt с явным Allow для GPTBot/ClaudeBot/PerplexityBot/Google-Extended/CCBot
- llms.txt + ai.txt (spawning.ai стандарт)
- IndexNow ключ 901a779d62ca4702ad810c863b45e1f7
- JSON-LD AutoPartsStore с адресом и 4 телефонами
- nginx:1.29-alpine runtime, контейнер на :4147
- Gitea Actions deploy.yml + Trivy scan + IndexNow ping
This commit is contained in:
Dmitry Gusev
2026-05-22 04:31:55 +03:00
parent 6ff1827690
commit ed27dcfc14
27 changed files with 6099 additions and 1 deletions

View File

@@ -0,0 +1,26 @@
---
import { ANALYTICS } from '../consts';
const ym = ANALYTICS.yandexMetrika;
const ga = ANALYTICS.googleGtag;
---
{ym && (
<script type="text/plain" data-cookieconsent="statistics" is:inline define:vars={{ ym }}>
(function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
m[i].l=1*new Date();
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)})
(window,document,"script","https://mc.yandex.ru/metrika/tag.js","ym");
window.ym(ym, "init", { clickmap:true, trackLinks:true, accurateTrackBounce:true, webvisor:true });
</script>
)}
{ga && (
<script type="text/plain" data-cookieconsent="statistics" is:inline src={`https://www.googletagmanager.com/gtag/js?id=${ga}`}></script>
)}
{ga && (
<script type="text/plain" data-cookieconsent="statistics" is:inline define:vars={{ ga }}>
window.dataLayer = window.dataLayer || [];
function gtag(){ dataLayer.push(arguments); }
gtag('js', new Date());
gtag('config', ga);
</script>
)}

View File

@@ -0,0 +1,77 @@
---
// Виджет на /privacy/: показывает текущее состояние согласия и две кнопки —
// «Отозвать согласие» (deny) и «Сбросить выбор» (показать баннер заново).
---
<div class="consent-revoke" id="consent-revoke">
<p class="cr-status" id="cr-status">Проверка статуса согласия…</p>
<div class="cr-buttons">
<button type="button" id="cr-deny" class="cr-btn cr-btn-secondary">Отозвать согласие</button>
<button type="button" id="cr-reset" class="cr-btn cr-btn-secondary">Сбросить выбор</button>
</div>
</div>
<style>
.consent-revoke {
border: 1px solid var(--rule);
background: var(--paper-soft);
padding: 1rem 1.25rem;
margin: 1.5rem 0;
}
.cr-status { margin: 0 0 0.75rem; font-size: 0.95rem; }
.cr-buttons { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.cr-btn {
font-family: inherit;
font-size: 0.88rem;
padding: 0.45rem 0.9rem;
border: 1px solid var(--ink);
background: var(--paper);
color: var(--ink);
cursor: pointer;
}
.cr-btn:hover { background: var(--ink); color: var(--paper); }
</style>
<script is:inline>
(function () {
const KEY = 'pit-consent';
const status = document.getElementById('cr-status');
const denyBtn = document.getElementById('cr-deny');
const resetBtn = document.getElementById('cr-reset');
if (!status) return;
function readConsent() {
try { return localStorage.getItem(KEY); } catch { return null; }
}
function setCookie(value) {
const exp = new Date(Date.now() + 365 * 24 * 3600 * 1000).toUTCString();
document.cookie = `${KEY}=${value}; expires=${exp}; path=/; SameSite=Lax`;
}
function clearCookie() {
document.cookie = `${KEY}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
}
function clearStored() {
try { localStorage.removeItem(KEY); } catch {}
clearCookie();
}
function render() {
const v = readConsent();
if (v === 'accept') status.textContent = 'Согласие на сбор аналитики дано. Скрипты Яндекс.Метрики и Google Analytics активны.';
else if (v === 'deny') status.textContent = 'Согласие отозвано. Аналитические скрипты не загружаются.';
else status.textContent = 'Выбор не сделан. При следующем визите появится баннер согласия.';
}
render();
denyBtn?.addEventListener('click', () => {
try { localStorage.setItem(KEY, 'deny'); } catch {}
setCookie('deny');
render();
location.reload();
});
resetBtn?.addEventListener('click', () => {
clearStored();
render();
location.reload();
});
})();
</script>

View File

@@ -0,0 +1,101 @@
---
// 152-ФЗ cookie consent baner. Хранит выбор в localStorage + cookie pit-consent.
// Активирует скрипты Analytics при согласии (см. Analytics.astro).
---
<div id="cookie-consent" hidden role="dialog" aria-labelledby="cc-title" aria-describedby="cc-desc">
<div class="cc-inner">
<div class="cc-text">
<strong id="cc-title">Мы используем cookies</strong>
<p id="cc-desc">
Сайт использует cookies и системы аналитики (Яндекс.Метрика, Google Analytics) для
анонимной статистики посещений. Подробнее — в <a href="/privacy/">политике конфиденциальности</a>.
</p>
</div>
<div class="cc-buttons">
<button type="button" id="cc-deny" class="cc-btn cc-btn-secondary">Отклонить</button>
<button type="button" id="cc-accept" class="cc-btn cc-btn-primary">Принять</button>
</div>
</div>
</div>
<style>
#cookie-consent {
position: fixed;
left: 1rem;
right: 1rem;
bottom: 1rem;
z-index: 1000;
background: var(--paper);
border: 1px solid var(--rule-strong);
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
padding: 1rem 1.25rem;
max-width: 720px;
margin: 0 auto;
font-family: var(--font-sans);
}
.cc-inner { display: flex; flex-direction: column; gap: 0.75rem; }
@media (min-width: 640px) {
.cc-inner { flex-direction: row; align-items: center; }
}
.cc-text strong { font-family: var(--font-serif); font-size: 1.05rem; }
.cc-text p { margin: 0.3rem 0 0; font-size: 0.88rem; color: var(--ink-soft); line-height: 1.5; }
.cc-buttons { display: flex; gap: 0.5rem; flex-shrink: 0; }
.cc-btn {
font-family: var(--font-sans);
font-size: 0.88rem;
font-weight: 500;
padding: 0.5rem 1rem;
border: 1px solid var(--rule-strong);
background: var(--paper);
color: var(--ink);
cursor: pointer;
}
.cc-btn-primary {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.cc-btn-primary:hover { background: var(--accent-soft); }
.cc-btn-secondary:hover { color: var(--accent); border-color: var(--accent); }
</style>
<script is:inline>
(function () {
const KEY = 'pit-consent';
const el = document.getElementById('cookie-consent');
if (!el) return;
function setCookie(value) {
const exp = new Date(Date.now() + 365 * 24 * 3600 * 1000).toUTCString();
document.cookie = `${KEY}=${value}; expires=${exp}; path=/; SameSite=Lax`;
}
function activateAnalytics() {
document.querySelectorAll('script[type="text/plain"][data-cookieconsent="statistics"]').forEach((s) => {
const n = document.createElement('script');
if (s.src) n.src = s.src;
else n.textContent = s.textContent || '';
if (s.async) n.async = true;
document.head.appendChild(n);
});
}
function decide(value) {
try { localStorage.setItem(KEY, value); } catch {}
setCookie(value);
el.hidden = true;
if (value === 'accept') activateAnalytics();
}
const saved = (() => {
try { return localStorage.getItem(KEY); } catch { return null; }
})();
if (saved === 'accept') { activateAnalytics(); return; }
if (saved === 'deny') return;
el.hidden = false;
document.getElementById('cc-accept')?.addEventListener('click', () => decide('accept'));
document.getElementById('cc-deny')?.addEventListener('click', () => decide('deny'));
})();
</script>

View File

@@ -0,0 +1,14 @@
---
import { SITE_TITLE } from '../consts';
const year = new Date().getFullYear();
---
<footer class="site-footer">
<div class="container footer-inner">
<p class="copy">© {year} {SITE_TITLE}. Все права защищены.</p>
<nav class="footer-nav">
<a href="/">Главная</a>
<a href="/privacy/">Конфиденциальность</a>
</nav>
</div>
</footer>

45
src/consts.ts Normal file
View File

@@ -0,0 +1,45 @@
/** Идентичность сайта. */
export const SITE_TITLE = 'Автозапчасти «ПитСтоп»';
export const SITE_TAGLINE = 'В наличии и под заказ';
export const SITE_DESCRIPTION =
'Магазин автозапчастей «ПитСтоп». Запчасти в наличии и под заказ. Москва, Люблинская 100, Аквапарк «ФЭНТАЗИ».';
export const SITE_URL = 'https://pitstopavto.su';
export const SITE_LANG = 'ru-RU';
/** Контакты магазина. */
export const ADDRESS = {
postal: '109382',
region: 'Москва',
street: 'ул. Люблинская, д. 100',
building: 'в здании Аквапарка «ФЭНТАЗИ»',
full: '109382, г. Москва, ул. Люблинская, д. 100, в здании Аквапарка «ФЭНТАЗИ»',
};
/** Кликабельные телефоны (display + tel: href). */
export const PHONES: Array<{ display: string; href: string }> = [
{ display: '8 (495) 369-58-44', href: 'tel:+74953695844' },
{ display: '8 (495) 592-62-31', href: 'tel:+74955926231' },
{ display: '8 (903) 544-24-19', href: 'tel:+79035442419' },
{ display: '8 (903) 759-50-29', href: 'tel:+79037595029' },
];
/** Геокоординаты для embed Яндекс.Карт (Люблинская 100). */
export const GEO = {
// Точка: Москва, Люблинская 100 (Аквапарк ФЭНТАЗИ)
lat: 55.658856,
lon: 37.747512,
zoom: 16,
};
/** Аналитика: реальные ID из текущего WP-сайта (перенесены 1:1). */
export const ANALYTICS = {
yandexMetrika: '47169531', // с Webvisor
googleGtag: 'GT-WRF7ZZ8',
};
/** ИП/ООО для политики конфиденциальности — заполнить когда заказчик пришлёт. */
export const OPERATOR = {
name: '',
inn: '',
email: '',
};

65
src/layouts/Base.astro Normal file
View File

@@ -0,0 +1,65 @@
---
import '@fontsource/ibm-plex-sans/400.css';
import '@fontsource/ibm-plex-sans/500.css';
import '@fontsource/ibm-plex-sans/700.css';
import '../styles/global.css';
import Footer from '../components/Footer.astro';
import CookieConsent from '../components/CookieConsent.astro';
import Analytics from '../components/Analytics.astro';
import { SITE_TITLE, SITE_DESCRIPTION, SITE_URL, SITE_LANG, ADDRESS, PHONES } from '../consts';
interface Props {
title?: string;
description?: string;
}
const { title, description = SITE_DESCRIPTION } = Astro.props;
const fullTitle = title ? `${title} — ${SITE_TITLE}` : `${SITE_TITLE} — в наличии и под заказ`;
const url = new URL(Astro.url.pathname, SITE_URL).toString();
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'AutoPartsStore',
'@id': `${SITE_URL}/#store`,
name: SITE_TITLE,
url: `${SITE_URL}/`,
description: SITE_DESCRIPTION,
telephone: PHONES.map((p) => p.display),
address: {
'@type': 'PostalAddress',
streetAddress: `${ADDRESS.street}, ${ADDRESS.building}`,
addressLocality: ADDRESS.region,
postalCode: ADDRESS.postal,
addressCountry: 'RU',
},
};
---
<!doctype html>
<html lang={SITE_LANG}>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{fullTitle}</title>
<meta name="description" content={description} />
<link rel="canonical" href={url} />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta property="og:type" content="website" />
<meta property="og:title" content={fullTitle} />
<meta property="og:description" content={description} />
<meta property="og:url" content={url} />
<meta property="og:locale" content="ru_RU" />
<meta property="og:site_name" content={SITE_TITLE} />
<meta name="twitter:card" content="summary" />
<script type="application/ld+json" is:inline set:html={JSON.stringify(jsonLd)} />
<Analytics />
</head>
<body>
<slot />
<Footer />
<CookieConsent />
</body>
</html>

63
src/pages/index.astro Normal file
View File

@@ -0,0 +1,63 @@
---
import Base from '../layouts/Base.astro';
import { SITE_TITLE, SITE_TAGLINE, ADDRESS, PHONES, GEO } from '../consts';
const mapSrc = `https://yandex.ru/map-widget/v1/?ll=${GEO.lon}%2C${GEO.lat}&z=${GEO.zoom}&pt=${GEO.lon}%2C${GEO.lat}%2Cpm2rdm`;
---
<Base>
<section class="hero">
<div class="container hero-inner">
<p class="hero-eyebrow">Магазин автозапчастей</p>
<h1>
Авто<span class="accent">запчасти</span><br />
«ПитСтоп»
</h1>
<p class="hero-tagline">{SITE_TAGLINE}. Москва, Люблинская 100.</p>
</div>
</section>
<section>
<div class="container">
<h2>Мы переехали</h2>
<p class="address-block">
<span class="address-postal">{ADDRESS.postal}</span><br />
<strong>г. {ADDRESS.region}, {ADDRESS.street}</strong><br />
{ADDRESS.building}
</p>
</div>
</section>
<section class="alt">
<div class="container">
<h2>Телефоны</h2>
<ul class="phones">
{PHONES.map((p) => (
<li>
<a href={p.href} aria-label={`Позвонить ${p.display}`}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.9.36 1.78.69 2.6a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.48-1.48a2 2 0 0 1 2.11-.45c.82.33 1.7.56 2.6.69A2 2 0 0 1 22 16.92z"/>
</svg>
{p.display}
</a>
</li>
))}
</ul>
</div>
</section>
<section>
<div class="container">
<h2>Как нас найти</h2>
<div class="map-wrap">
<iframe
src={mapSrc}
loading="lazy"
referrerpolicy="no-referrer-when-downgrade"
title="Карта проезда — Автозапчасти ПитСтоп"
allow="geolocation"
></iframe>
</div>
</div>
</section>
</Base>

86
src/pages/privacy.astro Normal file
View File

@@ -0,0 +1,86 @@
---
import Base from '../layouts/Base.astro';
import ConsentRevoke from '../components/ConsentRevoke.astro';
import { SITE_TITLE, SITE_URL } from '../consts';
const today = new Date().toISOString().slice(0, 10);
---
<Base title="Политика конфиденциальности">
<article class="prose">
<h1>Политика конфиденциальности</h1>
<p class="updated">Редакция от {today}</p>
<p>
Настоящая политика определяет порядок обработки персональных данных и сведений о
пользователях сайта <a href={SITE_URL}>{SITE_URL}</a> (далее — Сайт), принадлежащего
магазину {SITE_TITLE}, в соответствии с Федеральным законом № 152-ФЗ
«О персональных данных».
</p>
<h2>1. Какие данные собираются</h2>
<p>Сайт не содержит форм обратной связи, регистрации и заказов. Сами по себе персональные данные
посетителей не запрашиваются и не сохраняются на сервере Сайта.</p>
<p>
При посещении Сайта (при условии вашего согласия) подключаются системы сбора анонимной
статистики:
</p>
<ul>
<li>
<strong>Яндекс.Метрика</strong> (счётчик № <code>47169531</code>) — собирает обезличенные
данные о посещениях, в том числе IP-адрес, тип браузера и устройства, источник перехода,
просматриваемые страницы, действия на странице (включая запись сессий — Вебвизор).
</li>
<li>
<strong>Google Analytics 4</strong> (идентификатор <code>GT-WRF7ZZ8</code>) — собирает
обезличенные данные о посещениях, сессиях и устройствах.
</li>
</ul>
<p>
Данные обрабатываются операторами систем аналитики (ООО «ЯНДЕКС» и Google LLC) в соответствии
с их собственными политиками конфиденциальности. Сайт получает только агрегированные отчёты.
</p>
<h2>2. Cookies</h2>
<p>
Сайт использует следующие cookies:
</p>
<ul>
<li><code>pit-consent</code> — служебная cookie, хранит ваш выбор о согласии на аналитику
(срок 12 месяцев);</li>
<li>cookies Яндекс.Метрики (<code>_ym_*</code>) и Google Analytics (<code>_ga</code>,
<code>_gid</code>) — устанавливаются только после получения вашего согласия.</li>
</ul>
<h2>3. Цели обработки</h2>
<ul>
<li>анализ статистики посещений и качества Сайта;</li>
<li>улучшение удобства использования Сайта;</li>
<li>оценка эффективности рекламных каналов (при использовании).</li>
</ul>
<h2>4. Согласие и его отзыв</h2>
<p>
При первом посещении вам показывается баннер с возможностью принять или отклонить
использование систем аналитики. До получения согласия скрипты Яндекс.Метрики и Google
Analytics на странице не запускаются.
</p>
<p>
Вы можете в любой момент отозвать согласие или сбросить выбор:
</p>
<ConsentRevoke />
<h2>5. Контакты</h2>
<p>
Контактные данные магазина указаны на <a href="/">главной странице</a>. По вопросам
обработки данных вы можете связаться по любому из указанных там телефонов.
</p>
<h2>6. Изменения</h2>
<p>
Действующая редакция политики всегда доступна по адресу
<a href={`${SITE_URL}/privacy/`}>{SITE_URL}/privacy/</a>. Изменения вступают в силу с момента
публикации на этой странице.
</p>
</article>
</Base>

175
src/styles/global.css Normal file
View File

@@ -0,0 +1,175 @@
:root {
--ink: #0d0d0d;
--ink-soft: #4a4a4a;
--paper: #fafaf7;
--paper-soft: #f1efe8;
--rule: #d8d4c8;
--rule-strong: #b3ad9b;
--accent: #f5b400; /* янтарный — отсылка к авто-тематике */
--accent-soft: #d99a00;
--font-sans: 'IBM Plex Sans', system-ui, -apple-system, 'Segoe UI', sans-serif;
--reading-max: 64rem;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font-family: var(--font-sans);
color: var(--ink);
background: var(--paper);
line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
img, svg, iframe { max-width: 100%; display: block; }
a { color: inherit; text-decoration: none; }
a:hover { color: var(--accent-soft); }
.container {
max-width: var(--reading-max);
margin: 0 auto;
padding: 0 1.25rem;
}
/* ── Hero ───────────────────────────────────────────────────────── */
.hero {
background: var(--ink);
color: var(--paper);
padding: 4rem 0 3rem;
position: relative;
overflow: hidden;
}
.hero::before {
content: '';
position: absolute;
inset: 0;
background:
repeating-linear-gradient(135deg, transparent 0 16px, rgba(245,180,0,0.04) 16px 18px);
pointer-events: none;
}
.hero-inner { position: relative; }
.hero-eyebrow {
font-size: 0.78rem;
font-weight: 500;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--accent);
margin: 0 0 1rem;
}
.hero h1 {
font-size: clamp(2.4rem, 6vw, 4.2rem);
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.05;
margin: 0;
}
.hero h1 .accent { color: var(--accent); }
.hero-tagline {
font-size: clamp(1.05rem, 1.6vw, 1.25rem);
color: rgba(250,250,247,0.72);
margin: 1rem 0 0;
max-width: 38rem;
}
/* ── Sections ───────────────────────────────────────────────────── */
section { padding: 3.5rem 0; }
section.alt { background: var(--paper-soft); }
section h2 {
font-size: clamp(1.5rem, 3vw, 2rem);
font-weight: 700;
letter-spacing: -0.01em;
margin: 0 0 1.5rem;
display: flex;
align-items: center;
gap: 0.75rem;
}
section h2::before {
content: '';
display: inline-block;
width: 0.5rem;
height: 1.4rem;
background: var(--accent);
}
/* ── Адрес ──────────────────────────────────────────────────────── */
.address-block {
font-size: clamp(1.1rem, 1.6vw, 1.35rem);
line-height: 1.6;
max-width: 40rem;
}
.address-block strong { font-weight: 700; }
.address-postal { color: var(--ink-soft); font-size: 0.92rem; letter-spacing: 0.05em; }
/* ── Телефоны ───────────────────────────────────────────────────── */
.phones {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
gap: 0.75rem;
margin: 1.5rem 0 0;
padding: 0;
list-style: none;
}
.phones a {
display: flex;
align-items: center;
gap: 0.6rem;
background: var(--paper);
border: 1px solid var(--rule-strong);
padding: 0.85rem 1.1rem;
font-size: 1.1rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
transition: all 120ms ease;
}
.phones a:hover {
background: var(--ink);
color: var(--accent);
border-color: var(--ink);
}
.phones a svg { flex-shrink: 0; }
/* ── Карта ──────────────────────────────────────────────────────── */
.map-wrap {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
border: 1px solid var(--rule-strong);
overflow: hidden;
background: var(--paper-soft);
}
.map-wrap iframe { width: 100%; height: 100%; border: 0; }
@media (max-width: 640px) { .map-wrap { aspect-ratio: 4 / 3; } }
/* ── Footer ─────────────────────────────────────────────────────── */
.site-footer {
background: var(--ink);
color: rgba(250,250,247,0.7);
padding: 2rem 0;
margin-top: 4rem;
}
.footer-inner {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.footer-inner .copy { margin: 0; font-size: 0.9rem; }
.footer-nav { display: flex; gap: 1.25rem; font-size: 0.9rem; }
.footer-nav a:hover { color: var(--accent); }
/* ── Reading pages (privacy) ────────────────────────────────────── */
.prose {
max-width: 44rem;
margin: 3rem auto;
padding: 0 1.25rem;
}
.prose h1 {
font-size: clamp(1.8rem, 3.5vw, 2.4rem);
margin: 0 0 0.5rem;
}
.prose h2 { font-size: 1.3rem; margin: 2rem 0 0.5rem; }
.prose p, .prose li { font-size: 1rem; color: var(--ink); }
.prose ul, .prose ol { padding-left: 1.5rem; }
.prose .updated { color: var(--ink-soft); font-size: 0.85rem; margin: 0 0 2rem; }
.prose a { color: var(--accent-soft); text-decoration: underline; }