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

155
.astro/content.d.ts vendored Normal file
View File

@@ -0,0 +1,155 @@
declare module 'astro:content' {
export interface RenderResult {
Content: import('astro/runtime/server/index.js').AstroComponentFactory;
headings: import('astro').MarkdownHeading[];
remarkPluginFrontmatter: Record<string, any>;
}
interface Render {
'.md': Promise<RenderResult>;
}
export interface RenderedContent {
html: string;
metadata?: {
imagePaths: Array<string>;
[key: string]: unknown;
};
}
type Flatten<T> = T extends { [K: string]: infer U } ? U : never;
export type CollectionKey = keyof DataEntryMap;
export type CollectionEntry<C extends CollectionKey> = Flatten<DataEntryMap[C]>;
type AllValuesOf<T> = T extends any ? T[keyof T] : never;
export type ReferenceDataEntry<
C extends CollectionKey,
E extends keyof DataEntryMap[C] = string,
> = {
collection: C;
id: E;
};
export type ReferenceLiveEntry<C extends keyof LiveContentConfig['collections']> = {
collection: C;
id: string;
};
export function getCollection<C extends keyof DataEntryMap, E extends CollectionEntry<C>>(
collection: C,
filter?: (entry: CollectionEntry<C>) => entry is E,
): Promise<E[]>;
export function getCollection<C extends keyof DataEntryMap>(
collection: C,
filter?: (entry: CollectionEntry<C>) => unknown,
): Promise<CollectionEntry<C>[]>;
export function getLiveCollection<C extends keyof LiveContentConfig['collections']>(
collection: C,
filter?: LiveLoaderCollectionFilterType<C>,
): Promise<
import('astro').LiveDataCollectionResult<LiveLoaderDataType<C>, LiveLoaderErrorType<C>>
>;
export function getEntry<
C extends keyof DataEntryMap,
E extends keyof DataEntryMap[C] | (string & {}),
>(
entry: ReferenceDataEntry<C, E>,
): E extends keyof DataEntryMap[C]
? Promise<DataEntryMap[C][E]>
: Promise<CollectionEntry<C> | undefined>;
export function getEntry<
C extends keyof DataEntryMap,
E extends keyof DataEntryMap[C] | (string & {}),
>(
collection: C,
id: E,
): E extends keyof DataEntryMap[C]
? string extends keyof DataEntryMap[C]
? Promise<DataEntryMap[C][E]> | undefined
: Promise<DataEntryMap[C][E]>
: Promise<CollectionEntry<C> | undefined>;
export function getLiveEntry<C extends keyof LiveContentConfig['collections']>(
collection: C,
filter: string | LiveLoaderEntryFilterType<C>,
): Promise<import('astro').LiveDataEntryResult<LiveLoaderDataType<C>, LiveLoaderErrorType<C>>>;
/** Resolve an array of entry references from the same collection */
export function getEntries<C extends keyof DataEntryMap>(
entries: ReferenceDataEntry<C, keyof DataEntryMap[C]>[],
): Promise<CollectionEntry<C>[]>;
export function render<C extends keyof DataEntryMap>(
entry: DataEntryMap[C][string],
): Promise<RenderResult>;
export function reference<
C extends
| keyof DataEntryMap
// Allow generic `string` to avoid excessive type errors in the config
// if `dev` is not running to update as you edit.
// Invalid collection names will be caught at build time.
| (string & {}),
>(
collection: C,
): import('astro/zod').ZodPipe<
import('astro/zod').ZodString,
import('astro/zod').ZodTransform<
C extends keyof DataEntryMap
? {
collection: C;
id: string;
}
: never,
string
>
>;
type ReturnTypeOrOriginal<T> = T extends (...args: any[]) => infer R ? R : T;
type InferEntrySchema<C extends keyof DataEntryMap> = import('astro/zod').infer<
ReturnTypeOrOriginal<Required<ContentConfig['collections'][C]>['schema']>
>;
type ExtractLoaderConfig<T> = T extends { loader: infer L } ? L : never;
type InferLoaderSchema<
C extends keyof DataEntryMap,
L = ExtractLoaderConfig<ContentConfig['collections'][C]>,
> = L extends { schema: import('astro/zod').ZodSchema }
? import('astro/zod').infer<L['schema']>
: any;
type DataEntryMap = {
};
type ExtractLoaderTypes<T> = T extends import('astro/loaders').LiveLoader<
infer TData,
infer TEntryFilter,
infer TCollectionFilter,
infer TError
>
? { data: TData; entryFilter: TEntryFilter; collectionFilter: TCollectionFilter; error: TError }
: { data: never; entryFilter: never; collectionFilter: never; error: never };
type ExtractEntryFilterType<T> = ExtractLoaderTypes<T>['entryFilter'];
type ExtractCollectionFilterType<T> = ExtractLoaderTypes<T>['collectionFilter'];
type ExtractErrorType<T> = ExtractLoaderTypes<T>['error'];
type ExtractDataType<T> = ExtractLoaderTypes<T>['data'];
type LiveLoaderDataType<C extends keyof LiveContentConfig['collections']> =
LiveContentConfig['collections'][C]['schema'] extends undefined
? ExtractDataType<LiveContentConfig['collections'][C]['loader']>
: import('astro/zod').infer<
Exclude<LiveContentConfig['collections'][C]['schema'], undefined>
>;
type LiveLoaderEntryFilterType<C extends keyof LiveContentConfig['collections']> =
ExtractEntryFilterType<LiveContentConfig['collections'][C]['loader']>;
type LiveLoaderCollectionFilterType<C extends keyof LiveContentConfig['collections']> =
ExtractCollectionFilterType<LiveContentConfig['collections'][C]['loader']>;
type LiveLoaderErrorType<C extends keyof LiveContentConfig['collections']> = ExtractErrorType<
LiveContentConfig['collections'][C]['loader']
>;
export type ContentConfig = never;
export type LiveContentConfig = never;
}

2
.astro/types.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference types="astro/client" />
/// <reference path="content.d.ts" />

View File

@@ -0,0 +1,75 @@
name: deploy
on:
push:
branches: [main]
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install SSH client
run: |
apt-get update -qq
apt-get install -y --no-install-recommends openssh-client
- name: Setup SSH
run: |
mkdir -p ~/.ssh && chmod 700 ~/.ssh
printf '%s\n' "${{ secrets.SSH_DEPLOY_KEY }}" > ~/.ssh/id_deploy
chmod 600 ~/.ssh/id_deploy
printf '%s\n' "${{ secrets.SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
- name: Deploy + Trivy scan to web.hhivp.com
run: |
ssh -i ~/.ssh/id_deploy striker@web.hhivp.com bash -s <<'REMOTE'
set -euo pipefail
REPO_URL="ssh://git@git.striker.su:2222/striker/pitstopavto-su-v2.git"
DEPLOY_PATH="/opt/docker/sites/pitstopavto-su-v2"
HEALTH_URL="http://127.0.0.1:4147/"
if [ ! -d "$DEPLOY_PATH/.git" ]; then
mkdir -p "$DEPLOY_PATH"
git clone --branch main "$REPO_URL" "$DEPLOY_PATH"
else
cd "$DEPLOY_PATH"
git remote set-url origin "$REPO_URL"
git fetch origin main
git reset --hard origin/main
fi
cd "$DEPLOY_PATH"
docker compose build
echo "=== Trivy scan: pitstopavto-su-v2:latest ==="
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /tmp/trivy-cache:/root/.cache/ \
ghcr.io/aquasecurity/trivy:latest image \
--severity HIGH,CRITICAL \
--ignore-unfixed \
--no-progress \
--exit-code 0 \
--timeout 5m \
pitstopavto-su-v2:latest || true
echo "=== Trivy scan done ==="
docker compose up -d
sleep 5
docker compose ps
curl -fsS -o /dev/null -w "HEALTH HTTP %{http_code}\n" "$HEALTH_URL"
docker image prune -af --filter "until=168h" >/dev/null 2>&1 || true
REMOTE
- name: Setup Node.js for IndexNow
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Notify IndexNow
run: npm run indexnow || true

105
CLAUDE.md Normal file
View File

@@ -0,0 +1,105 @@
# pitstopavto.su — v2 (Astro)
Сайт-визитка магазина автозапчастей «ПитСтоп». Редизайн с WordPress на статический Astro 5.
**Прод:** https://pitstopavto.su
**Репо:** `git.striker.su/striker/pitstopavto-su-v2`
**Хост:** `web.hhivp.com` (45.10.53.206 / 45.10.53.242)
**Контейнер:** `pitstopavto-su-v2` на `127.0.0.1:4147` (nginx:alpine + Astro SSG)
**Cutover:** запланирован после согласования дизайна. Старый WP — `pitstopavto-su:4142`.
## Стек
- **Astro 5** SSG, две страницы: `/` и `/privacy/`
- **nginx:1.29-alpine** в runtime-контейнере
- **IBM Plex Sans** (через `@fontsource/ibm-plex-sans`)
- **@astrojs/sitemap** — `sitemap-index.xml` автоматически
## Структура
```
src/
├── components/
│ ├── Analytics.astro Яндекс.Метрика (47169531 с Webvisor) + GA4 (GT-WRF7ZZ8) в type=text/plain
│ ├── CookieConsent.astro 152-ФЗ-баннер, ключ pit-consent в localStorage+cookie
│ ├── ConsentRevoke.astro на /privacy/ — отозвать/сбросить выбор
│ └── Footer.astro
├── layouts/Base.astro <html>+meta+JSON-LD (AutoPartsStore)+Analytics+Footer+CookieConsent
├── pages/
│ ├── index.astro hero / адрес / телефоны / карта Яндекса
│ └── privacy.astro политика конфиденциальности 152-ФЗ
├── styles/global.css тёмный/янтарный (#f5b400) индустриальный стиль
└── consts.ts SITE_*, ADDRESS, PHONES, GEO, ANALYTICS, OPERATOR
public/
├── 901a779d62ca4702ad810c863b45e1f7.txt IndexNow key
├── ai.txt spawning.ai (Train/Cite/Quote: yes)
├── favicon.svg
├── llms.txt
└── robots.txt явные Allow для GPTBot/ClaudeBot/PerplexityBot/Google-Extended/CCBot
scripts/indexnow.js POST sitemap → yandex.com/indexnow + api.indexnow.org
```
## Аналитика (перенесена 1:1 из WP)
- **Яндекс.Метрика № 47169531** с Webvisor (запись сессий)
- **Google Analytics GT-WRF7ZZ8** (gtag)
Скрипты в `Analytics.astro` имеют `type="text/plain" data-cookieconsent="statistics"` и активируются только после клика «Принять» в баннере `CookieConsent.astro`. Согласие хранится в `localStorage` + cookie `pit-consent` (12 мес). На `/privacy/` есть виджет для отзыва/сброса согласия.
## Деплой
### Автоматический (Gitea Actions)
Push в `main``.gitea/workflows/deploy.yml`:
1. SSH на `web.hhivp.com` с ключом из секрета `SSH_DEPLOY_KEY`
2. `git fetch + reset --hard origin/main`
3. `docker compose build && up -d`
4. Trivy scan (warning-only)
5. `curl http://127.0.0.1:4147/` — health check
6. `docker image prune --filter "until=168h"`
7. `npm run indexnow` (ping Яндекса и Bing)
**Требуемые секреты в Gitea** (`/repos/striker/pitstopavto-su-v2/actions/secrets`):
- `SSH_DEPLOY_KEY` — приватный ed25519 ключ `~/.ssh/pitstopavto-v2-deploy`
- `SSH_KNOWN_HOSTS``web.hhivp.com ssh-ed25519 AAAAC3...`
Pubkey деплоя кладётся в `striker@web:~/.ssh/authorized_keys`.
### Вручную
```bash
ssh striker@web.hhivp.com
cd /opt/docker/sites/pitstopavto-su-v2
git pull && docker compose up -d --build
```
## Откат на старый WP
Старый WP-контейнер `pitstopavto-su:4142` + БД `pitstopavto_su` на `db.hhivp.com` сохранены (для отката). Откат за ~30 секунд:
```bash
ssh striker@web.hhivp.com
sudo sed -i 's|127.0.0.1:4147|127.0.0.1:4142|g' /etc/nginx/conf.d/pitstopavto.su
sudo nginx -t && sudo systemctl reload nginx
```
После ~14 дней стабильной работы v2 — старый WP можно удалить (контейнер + БД + образ).
## Локальная разработка
```bash
npm install
npm run dev # http://localhost:4321
npm run build # → dist/ (статика)
npm run preview
```
## IndexNow
Уникальный ключ `901a779d62ca4702ad810c863b45e1f7` в `public/<key>.txt` и в `scripts/indexnow.js`. Запускается из CI после успешного healthcheck.
## История
- **2026-05-22:** инициализация v2 (скаффолд Astro). Старый WP-сайт — одностраничник «Мы переехали» (Люблинская 100, Аквапарк ФЭНТАЗИ, 4 телефона), все остальные пункты меню в WP возвращали 404, 20+ плагинов держались ради одной страницы.

23
Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
# syntax=docker/dockerfile:1
# ─── Stage 1: build static site (Astro SSG) ────────────────────────────────
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install --no-audit --no-fund
COPY . .
RUN npm run build
# ─── Stage 2: nginx runtime ─────────────────────────────────────────────────
FROM nginx:1.29-alpine
RUN rm -rf /usr/share/nginx/html/*
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD wget -q --spider http://127.0.0.1/ || exit 1

View File

@@ -1,3 +1,17 @@
# pitstopavto-su-v2 # pitstopavto-su-v2
<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20> <20><><EFBFBD><EFBFBD>-<2D><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> (Astro 5, <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> WordPress) Сайт-визитка магазина автозапчастей «ПитСтоп» — Astro 5 SSG, замена WordPress.
**Прод:** https://pitstopavto.su
**Контейнер:** `pitstopavto-su-v2` на `web.hhivp.com:4147`
См. [CLAUDE.md](./CLAUDE.md) для деталей стека, деплоя и отката.
## Быстрый старт
```bash
npm install
npm run dev # http://localhost:4321
npm run build # → dist/
npm run preview
```

12
astro.config.mjs Normal file
View File

@@ -0,0 +1,12 @@
// @ts-check
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://pitstopavto.su',
trailingSlash: 'always',
build: {
format: 'directory',
},
integrations: [sitemap()],
});

22
docker-compose.yml Normal file
View File

@@ -0,0 +1,22 @@
services:
pitstopavto-su-v2:
build:
context: .
image: pitstopavto-su-v2:latest
container_name: pitstopavto-su-v2
restart: unless-stopped
ports:
- "127.0.0.1:4147:80"
cap_drop: [ALL]
cap_add: [NET_BIND_SERVICE, CHOWN, SETUID, SETGID, DAC_OVERRIDE]
security_opt: [no-new-privileges:true]
tmpfs:
- /tmp:noexec,nosuid,size=16m
- /var/cache/nginx:size=32m
- /var/run:size=4m
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1/"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s

57
nginx.conf Normal file
View File

@@ -0,0 +1,57 @@
# nginx-конфиг внутри контейнера. TLS терминируется на хосте (web.hhivp.com).
server {
listen 80 default_server;
server_name _;
root /usr/share/nginx/html;
index index.html;
access_log /dev/stdout;
error_log /dev/stderr warn;
location ~ /\.(?!well-known) {
deny all;
log_not_found off;
access_log off;
}
gzip on;
gzip_vary on;
gzip_min_length 256;
gzip_proxied any;
gzip_comp_level 6;
gzip_types
text/plain text/css text/xml
application/json application/xml
application/javascript application/x-javascript
image/svg+xml font/woff2;
location = /sitemap.txt { default_type text/plain; charset utf-8; }
location = /robots.txt { default_type text/plain; charset utf-8; }
location = /ai.txt { default_type text/plain; charset utf-8; }
location = /llms.txt { default_type text/plain; charset utf-8; }
location /_astro/ {
expires 1y;
add_header Cache-Control "public, immutable, max-age=31536000";
access_log off;
}
location ~* \.(?:css|js|woff2?|ttf|otf|eot)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
access_log off;
}
location ~* \.(?:png|jpe?g|gif|svg|webp|avif|ico)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
access_log off;
}
# Маршрутизация: Astro генерит dir/index.html
location / {
try_files $uri $uri/ $uri.html =404;
}
error_page 404 /404.html;
location = /404.html { internal; }
}

4831
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "pitstopavto-su-v2",
"type": "module",
"version": "0.1.0",
"private": true,
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
"indexnow": "node scripts/indexnow.js"
},
"dependencies": {
"@astrojs/sitemap": "^3.6.0",
"@fontsource/ibm-plex-sans": "^5.2.5",
"astro": "^6.3.6"
}
}

View File

@@ -0,0 +1 @@
901a779d62ca4702ad810c863b45e1f7

7
public/ai.txt Normal file
View File

@@ -0,0 +1,7 @@
# AI usage policy — pitstopavto.su
# Стандарт spawning.ai (https://site.spawning.ai/ai-txt)
User-Agent: *
Train: yes
Cite: yes
Quote: yes

6
public/favicon.svg Normal file
View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="8" fill="#0d0d0d"/>
<text x="50%" y="50%" dominant-baseline="central" text-anchor="middle"
font-family="IBM Plex Sans, Segoe UI, sans-serif" font-size="28" font-weight="700"
fill="#f5b400">ПС</text>
</svg>

After

Width:  |  Height:  |  Size: 322 B

17
public/llms.txt Normal file
View File

@@ -0,0 +1,17 @@
# Автозапчасти «ПитСтоп»
> Магазин автозапчастей в Москве. Запчасти в наличии и под заказ.
## Контакты
- Адрес: 109382, г. Москва, ул. Люблинская, д. 100, в здании Аквапарка «ФЭНТАЗИ»
- Телефоны:
- 8 (495) 369-58-44
- 8 (495) 592-62-31
- 8 (903) 544-24-19
- 8 (903) 759-50-29
## Страницы
- [Главная](https://pitstopavto.su/) — адрес, телефоны, карта проезда
- [Политика конфиденциальности](https://pitstopavto.su/privacy/) — обработка данных по 152-ФЗ

20
public/robots.txt Normal file
View File

@@ -0,0 +1,20 @@
User-agent: *
Allow: /
# AI / LLM crawlers — явный allow для прозрачности
User-agent: GPTBot
Allow: /
User-agent: ClaudeBot
Allow: /
User-agent: anthropic-ai
Allow: /
User-agent: PerplexityBot
Allow: /
User-agent: Google-Extended
Allow: /
User-agent: CCBot
Allow: /
Sitemap: https://pitstopavto.su/sitemap-index.xml
Sitemap: https://pitstopavto.su/sitemap.txt
Host: https://pitstopavto.su

75
scripts/indexnow.js Normal file
View File

@@ -0,0 +1,75 @@
// IndexNow: уведомить Yandex/Bing о новых/обновлённых URL.
// Запуск: node scripts/indexnow.js (из CI после деплоя или вручную)
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const root = path.resolve(__dirname, '..');
const BASE = 'https://pitstopavto.su';
const HOST = 'pitstopavto.su';
const KEY = '901a779d62ca4702ad810c863b45e1f7';
const keyFile = path.join(root, 'public', `${KEY}.txt`);
if (!fs.existsSync(keyFile)) {
fs.writeFileSync(keyFile, KEY, 'utf-8');
console.log(`created key file: public/${KEY}.txt`);
}
function extractLocs(xml) {
return [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1].trim());
}
let urls = [];
const distDir = path.join(root, 'dist');
const sitemapIndex = path.join(distDir, 'sitemap-index.xml');
if (fs.existsSync(sitemapIndex)) {
const idx = fs.readFileSync(sitemapIndex, 'utf-8');
for (const sub of extractLocs(idx)) {
const fname = sub.split('/').pop();
const localPath = path.join(distDir, fname);
if (fs.existsSync(localPath)) {
urls.push(...extractLocs(fs.readFileSync(localPath, 'utf-8')));
}
}
} else {
console.log('no local sitemap-index.xml, fetching from production…');
try {
const idxResp = await fetch(`${BASE}/sitemap-index.xml`);
if (!idxResp.ok) throw new Error(`HTTP ${idxResp.status}`);
for (const sub of extractLocs(await idxResp.text())) {
const subResp = await fetch(sub);
if (!subResp.ok) continue;
urls.push(...extractLocs(await subResp.text()));
}
} catch (e) {
console.error(`failed to fetch sitemap from ${BASE}: ${e.message}`);
process.exit(1);
}
}
urls = [...new Set(urls)].filter((u) => u.startsWith(BASE));
if (urls.length === 0) { console.error('no URLs collected'); process.exit(1); }
console.log(`Submitting ${urls.length} URLs to IndexNow…`);
const payload = { host: HOST, key: KEY, keyLocation: `${BASE}/${KEY}.txt`, urlList: urls };
async function submit(endpoint) {
try {
const r = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify(payload),
});
console.log(` ${endpoint}: HTTP ${r.status}`);
} catch (e) {
console.error(` ${endpoint}: ${e.message}`);
}
}
await Promise.all([
submit('https://yandex.com/indexnow'),
submit('https://api.indexnow.org/indexnow'),
]);

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; }

3
tsconfig.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "astro/tsconfigs/strict"
}