feat: скаффолд Astro 5 SSG (главная + /privacy + consent gate)
Some checks failed
deploy / deploy (push) Failing after 14s
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:
155
.astro/content.d.ts
vendored
Normal file
155
.astro/content.d.ts
vendored
Normal 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
2
.astro/types.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/// <reference types="astro/client" />
|
||||||
|
/// <reference path="content.d.ts" />
|
||||||
75
.gitea/workflows/deploy.yml
Normal file
75
.gitea/workflows/deploy.yml
Normal 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
105
CLAUDE.md
Normal 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
23
Dockerfile
Normal 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
|
||||||
16
README.md
16
README.md
@@ -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
12
astro.config.mjs
Normal 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
22
docker-compose.yml
Normal 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
57
nginx.conf
Normal 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
4831
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
package.json
Normal file
21
package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
public/901a779d62ca4702ad810c863b45e1f7.txt
Normal file
1
public/901a779d62ca4702ad810c863b45e1f7.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
901a779d62ca4702ad810c863b45e1f7
|
||||||
7
public/ai.txt
Normal file
7
public/ai.txt
Normal 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
6
public/favicon.svg
Normal 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
17
public/llms.txt
Normal 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
20
public/robots.txt
Normal 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
75
scripts/indexnow.js
Normal 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'),
|
||||||
|
]);
|
||||||
26
src/components/Analytics.astro
Normal file
26
src/components/Analytics.astro
Normal 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>
|
||||||
|
)}
|
||||||
77
src/components/ConsentRevoke.astro
Normal file
77
src/components/ConsentRevoke.astro
Normal 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>
|
||||||
101
src/components/CookieConsent.astro
Normal file
101
src/components/CookieConsent.astro
Normal 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>
|
||||||
14
src/components/Footer.astro
Normal file
14
src/components/Footer.astro
Normal 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
45
src/consts.ts
Normal 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
65
src/layouts/Base.astro
Normal 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
63
src/pages/index.astro
Normal 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
86
src/pages/privacy.astro
Normal 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
175
src/styles/global.css
Normal 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
3
tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "astro/tsconfigs/strict"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user