feat: complete backend B1-B7 — Payload CMS, ezy payments, leads, deploy
Some checks are pending
CI / Type Check (push) Waiting to run
CI / Lint (push) Waiting to run
CI / Unit Tests (push) Waiting to run
Deploy / Build & Push Image (push) Waiting to run
Deploy / Deploy to VPS (push) Blocked by required conditions

- B1: Next.js 15 + Payload CMS 3.0 + Postgres 16, ESLint, Prettier, Husky, Vitest
- B2: 9 collections, 6 globals, 12 Page Builder blocks, access control, slugify/revalidate hooks
- B3: ezy.com.ua payments, Binotel HMAC webhook, leads API, Telegram bot, Resend email, rate limiting
- B4: Tariffs collection with ezy API sync (cron + manual), dynamic pricing source-of-truth
- B5: 13 test files covering unit libs and all API routes
- B6: Dockerfile multi-stage, docker-compose.prod.yml, nginx.conf SSL, GitHub Actions CI/CD, health endpoint
- B7: docs/admin-guide-ua.md (marketer guide), docs/deploy.md (VPS instructions), README quickstart

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-09 19:14:54 +01:00
parent 83fad62732
commit 9b41fa447a
113 changed files with 15923 additions and 0 deletions

14
.dockerignore Normal file
View file

@ -0,0 +1,14 @@
node_modules
.next
.env
.env.*
.git
.gitignore
*.md
coverage
.claude
.claude-flow
.swarm
ruvector.db
agentdb.rvf
agentdb.rvf.lock

59
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,59 @@
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
typecheck:
name: Type Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 11
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run type check
run: pnpm typecheck
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 11
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run linter
run: pnpm lint
test:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 11
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run tests
run: pnpm test

81
.github/workflows/deploy.yml vendored Normal file
View file

@ -0,0 +1,81 @@
name: Deploy
on:
push:
branches: [main]
concurrency:
group: deploy-production
cancel-in-progress: false
jobs:
build-and-push:
name: Build & Push Image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image_tag: ${{ steps.meta.outputs.version }}
steps:
- uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/shumiland
tags: |
type=sha,prefix=sha-,format=short
type=raw,value=latest
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
name: Deploy to VPS
runs-on: ubuntu-latest
needs: build-and-push
environment: production
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
envs: IMAGE_TAG
script: |
cd /opt/shumiland
TAG="$IMAGE_TAG" docker compose -f docker-compose.prod.yml pull app
TAG="$IMAGE_TAG" docker compose -f docker-compose.prod.yml up -d --no-deps app
docker system prune -f --filter "until=24h"
env:
IMAGE_TAG: ${{ needs.build-and-push.outputs.image_tag }}
- name: Health check
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
sleep 10
curl -sf http://localhost:3000/api/health || exit 1

20
.gitignore vendored
View file

@ -25,3 +25,23 @@ Thumbs.db
# Logs
*.log
# Next.js
.next/
out/
# Payload generated
src/payload-types.ts
src/generated-schema.graphql
# Build
build/
# Coverage
coverage/
# Migrations (auto-generated, committed separately)
# migrations/*.ts
# pnpm
.pnpm-store/

1
.husky/pre-commit Executable file
View file

@ -0,0 +1 @@
npx lint-staged

1
.npmrc Normal file
View file

@ -0,0 +1 @@
shamefully-hoist=false

7
.prettierrc.json Normal file
View file

@ -0,0 +1,7 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "es5",
"printWidth": 100,
"plugins": ["prettier-plugin-tailwindcss"]
}

33
Dockerfile Normal file
View file

@ -0,0 +1,33 @@
# syntax=docker/dockerfile:1
# ---- Base ----
FROM node:22-alpine AS base
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
# ---- Dependencies ----
FROM base AS deps
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
# ---- Builder ----
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm run build
# ---- Runner ----
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
CMD ["node", "server.js"]

View file

@ -1,2 +1,59 @@
# Shumiland
Website for Shumiland entertainment park — Next.js 15 + Payload CMS 3.0 + PostgreSQL 16.
## Quickstart (dev)
```bash
# 1. Install dependencies
pnpm install
# 2. Copy env and fill in values
cp .env.example .env
# 3. Start database
docker compose up postgres -d
# 4. Run dev server
pnpm dev
```
Open [http://localhost:3000/admin](http://localhost:3000/admin) to access the CMS admin.
On first run, Payload will prompt you to create an admin user.
## Commands
| Command | Description |
| ---------------- | --------------------------------- |
| `pnpm dev` | Start dev server with hot reload |
| `pnpm build` | Production build |
| `pnpm start` | Start production server |
| `pnpm lint` | ESLint check |
| `pnpm typecheck` | TypeScript check |
| `pnpm test` | Run tests |
| `pnpm format` | Prettier format |
| `pnpm seed` | Seed initial admin user + globals |
## Project structure
```
src/
├── app/api/ # API routes (leads, tickets, binotel, health, revalidate)
├── collections/ # Payload CMS collections (Pages, Blog, Tariffs, Leads, Orders, ...)
├── globals/ # Payload globals (Header, Footer, HomePage, SiteSettings, ...)
├── blocks/ # Page Builder blocks (Hero, Gallery, LeadForm, PricingBlock, ...)
├── lib/ # Integrations (ezy, binotel, telegram, resend, rateLimit, ...)
└── access/ # Access control helpers
tests/
├── unit/ # Unit tests (lib functions)
└── api/ # API route tests
docs/
├── admin-guide-ua.md # CMS guide for marketers (Ukrainian)
└── deploy.md # VPS deployment instructions
```
## Docs
- [Admin guide (UA)](docs/admin-guide-ua.md) — for marketers, no technical knowledge required
- [Deploy guide](docs/deploy.md) — VPS setup, secrets, CI/CD, backup/restore

70
docker-compose.prod.yml Normal file
View file

@ -0,0 +1,70 @@
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: shumiland
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: shumiland
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U shumiland']
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
app:
image: ghcr.io/aimpress/shumiland:${TAG:-latest}
env_file: .env.production
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
nginx:
image: nginx:alpine
ports:
- '80:80'
- '443:443'
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./certbot/conf:/etc/letsencrypt:ro
- ./certbot/www:/var/www/certbot:ro
depends_on:
- app
restart: unless-stopped
certbot:
image: certbot/certbot
volumes:
- ./certbot/conf:/etc/letsencrypt
- ./certbot/www:/var/www/certbot
command: >-
certonly --webroot
--webroot-path=/var/www/certbot
--email ${CERTBOT_EMAIL}
--agree-tos
--no-eff-email
-d ${CERTBOT_DOMAIN}
pg_backup:
image: alpine:3
volumes:
- postgres_data:/var/lib/postgresql/data:ro
- ./backups:/backups
environment:
POSTGRES_USER: shumiland
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: shumiland
POSTGRES_HOST: postgres
command: >-
sh -c "apk add --no-cache postgresql-client &&
echo '0 3 * * * pg_dump postgresql://$$POSTGRES_USER:$$POSTGRES_PASSWORD@$$POSTGRES_HOST/$$POSTGRES_DB | gzip > /backups/shumiland_$$(date +\%Y\%m\%d_\%H\%M\%S).sql.gz && find /backups -mtime +14 -delete' | crontab - &&
crond -f"
depends_on:
- postgres
restart: unless-stopped
volumes:
postgres_data:

24
docker-compose.yml Normal file
View file

@ -0,0 +1,24 @@
version: '3.9'
# Dev compose — only Postgres. Run `pnpm dev` locally for the app.
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
ports:
- '5432:5432'
environment:
POSTGRES_USER: shumiland
POSTGRES_PASSWORD: shumiland # dev only — override via .env in production
POSTGRES_DB: shumiland
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U shumiland']
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres_data:

176
docs/admin-guide-ua.md Normal file
View file

@ -0,0 +1,176 @@
# Керівництво адміністратора — Shumiland
Цей посібник для маркетологів та контент-менеджерів. Технічних знань не потрібно.
---
## Вхід в адмінпанель
1. Відкрийте браузер і перейдіть за адресою: **https://shumiland.com.ua/admin**
2. Введіть email та пароль, які вам надали.
3. Натисніть **«Увійти»**.
> Якщо забули пароль — зверніться до розробника для скидання.
---
## Навігація
Ліве меню містить:
| Розділ | Призначення |
| --------------- | ------------------------------------ |
| **Сторінки** | Всі сторінки сайту |
| **Блог** | Статті та новини |
| **Тарифи** | Квитки (ціни тягнуться з каси ezy) |
| **Ліди** | Заявки від відвідувачів |
| **Замовлення** | Замовлення квитків |
| **Медіа** | Зображення та файли |
| **Globals** | Шапка, підвал, Головна, Налаштування |
| **Користувачі** | Облікові записи адмінпанелі |
---
## Сторінки
### Створення нової сторінки
1. Натисніть **«Сторінки»** → **«Створити»**.
2. Заповніть поле **«Заголовок»**.
3. Поле **«Slug»** (адреса сторінки) заповниться автоматично, наприклад `pro-park`.
- Можна відредагувати вручну — лише латинські літери, цифри та дефіси.
4. Перетягуйте блоки з панелі **«Блоки»** для побудови структури сторінки.
5. Натисніть **«Зберегти чернетку»** — сторінка не буде видна відвідувачам.
6. Коли готово — натисніть **«Опублікувати»**.
### Редагування існуючої сторінки
1. **Сторінки** → клік на назву.
2. Відредагуйте потрібні блоки.
3. **«Зберегти чернетку»** або **«Опублікувати»**.
### Приховати / знову показати сторінку
- Відкрийте сторінку → змініть **Статус** на **«Чернетка»** → **«Зберегти»**.
- Сторінка зникне з сайту, але не видалиться.
---
## Блоки сторінки (Page Builder)
Кожен блок можна додати кількаразово та перетягувати у будь-якому порядку.
| Блок | Що робить |
| ---------------- | ---------------------------------------------------------- |
| **Hero** | Банер з фоновим відео або зображенням + заголовок + кнопка |
| **Текст** | Форматований текст (жирний, курсив, списки, посилання) |
| **Зображення** | Одне зображення з підписом |
| **Відео** | Вбудоване відео (YouTube / Vimeo) |
| **Галерея** | Кілька фото у сітці |
| **Форма заявки** | Налаштовувана контактна форма |
| **Тарифи** | Блок вибору квитків (дані з каси ezy) |
| **CTA** | Заклик до дії — кнопка + підзаголовок |
| **Локації** | Картки локацій парку |
| **Переваги** | «Чому обирають Shumiland» |
| **Новини** | Останні N статей з блогу |
| **Підписка** | Форма підписки на розсилку |
| **Rich Text** | Повнофункціональний редактор Lexical |
---
## Медіа (завантаження фото)
1. **Медіа****«Завантажити»**.
2. Виберіть файл (JPG, PNG, WebP, SVG — до 20 МБ).
3. Обов'язково заповніть поле **«Alt текст»** — це важливо для SEO та доступності.
4. Натисніть **«Зберегти»**.
> Завантажені файли одразу доступні у всіх блоках при виборі зображення.
---
## Globals (шапка, підвал, головна)
**Globals** — це глобальні елементи сайту, які існують в одному екземплярі.
### Шапка (Header)
**Globals → Header**
- Змініть логотип або навігаційні посилання.
- Натисніть **«Зберегти»** — зміни з'являться на всіх сторінках одразу.
### Підвал (Footer)
**Globals → Footer**
- Контакти, посилання в соцмережах, копірайт.
### Головна сторінка
**Globals → HomePage**
- Керуйте блоками головної: hero, локації, переваги, новини, підписка.
### Налаштування сайту
**Globals → SiteSettings**
- GA4 ID (аналітика), Binotel ID (телефонія).
---
## Блог
### Створення статті
1. **Блог****«Створити»**.
2. Заповніть **«Заголовок»**, **«Slug»** (автозаповнення), **«Зображення-обкладинка»**.
3. Напишіть текст у редакторі.
4. Виберіть **Категорії** та **Теги**.
5. **«Опублікувати»** або **«Зберегти чернетку»**.
---
## Тарифи (квитки)
### Важливо
Ціни та назви тарифів **завжди беруться з каси ezy** автоматично. Ви не можете змінити ціну — тільки касир ezy.
### Що можна змінити в адмінпанелі
**Тарифи** → виберіть тариф:
| Поле | Опис |
| ----------------------- | -------------------------------------------- |
| **Відображувана назва** | Замінює стандартну назву з ezy (опціонально) |
| **Опис** | Короткий текст під назвою квитка |
| **Зображення** | Фото для картки тарифу |
| **Категорія** | Групування: динопарк / лабіринт / сімейний |
| **Сортування** | Порядок відображення (менше = вище) |
| **Видимий** | Приховати тариф з сайту (не видаляє з ezy) |
### Синхронізація тарифів з ezy
Синхронізація відбувається автоматично щогодини. Щоб оновити вручну:
1. **Тарифи** → кнопка **«Синхронізувати з ezy»** (якщо є) або зверніться до розробника.
---
## Ліди та замовлення
- **Ліди** — заявки з форм сайту (ім'я, телефон, джерело). Статуси: `new`, `contacted`, `closed`.
- **Замовлення** — записи після натискання «Оплатити». Статус `redirected_to_payment` означає, що людина перейшла на оплату. Фактичну оплату підтверджує каса ezy.
> Telegram-бот надсилає сповіщення одразу після появи нового ліду або замовлення.
---
## Швидкі підказки
- Завжди **публікуйте** сторінку, коли хочете показати її відвідувачам — «Зберегти чернетку» не публікує.
- Для видалення — лише якщо впевнені; краще використовуйте **приховування** (статус «Чернетка»).
- Alt текст у медіа — **обов'язково** для всіх зображень.
- Slug **не змінюйте** після публікації — це зламає посилання на сторінку.

244
docs/deploy.md Normal file
View file

@ -0,0 +1,244 @@
# Deployment Guide — Shumiland VPS
## Prerequisites
- VPS with Ubuntu 22.04+, Docker + Docker Compose v2 installed
- Domain `shumiland.com.ua` DNS A-record pointing to VPS IP
- SSH access as non-root user with sudo
- GitHub Actions secrets configured (see CI/CD section)
---
## First Deploy
### 1. Clone the repository
```bash
git clone git@github.com:aimpress/shumiland-site-dev.git /opt/shumiland
cd /opt/shumiland
```
### 2. Create `.env.production`
```bash
cp .env.example .env.production
nano .env.production
```
Fill in all values (see table below). Generate secrets with:
```bash
openssl rand -base64 32
```
| Variable | Value |
| ---------------------- | -------------------------------------------------------------------- |
| `DATABASE_URL` | `postgresql://shumiland:<POSTGRES_PASSWORD>@postgres:5432/shumiland` |
| `POSTGRES_PASSWORD` | Generate with openssl |
| `PAYLOAD_SECRET` | Generate with openssl |
| `REVALIDATE_SECRET` | Generate with openssl |
| `SYNC_SECRET` | Generate with openssl |
| `CRON_SECRET` | Generate with openssl |
| `EZY_PARTNER_KEY` | From ezy.com.ua dashboard |
| `EZY_ACTIVITY` | From ezy.com.ua dashboard |
| `TELEGRAM_BOT_TOKEN` | From BotFather |
| `TELEGRAM_CHAT_ID` | Manager group chat ID |
| `RESEND_API_KEY` | From resend.com |
| `MANAGER_EMAILS` | Comma-separated manager emails |
| `BINOTEL_HMAC_SECRET` | From Binotel dashboard → Webhooks |
| `CERTBOT_DOMAIN` | `shumiland.com.ua` |
| `CERTBOT_EMAIL` | admin@ai-impress.com |
| `NEXT_PUBLIC_SITE_URL` | `https://shumiland.com.ua` |
### 3. Obtain SSL certificate
Run certbot once to get the certificate before starting nginx:
```bash
# Start only postgres and certbot (nginx needs cert to start)
docker compose -f docker-compose.prod.yml run --rm certbot
# Verify cert created
ls certbot/conf/live/shumiland.com.ua/
```
### 4. Pull image and start all services
```bash
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d
```
### 5. Run database migrations
```bash
docker compose -f docker-compose.prod.yml exec app npx payload migrate
```
### 6. Seed initial data and sync tariffs
```bash
# Create admin user + seed globals
docker compose -f docker-compose.prod.yml exec app pnpm seed
# Sync tariffs from ezy API
curl -X POST https://shumiland.com.ua/api/tariffs/sync \
-H "Authorization: Bearer <SYNC_SECRET>"
```
### 7. Verify health
```bash
curl https://shumiland.com.ua/api/health
# Expected: {"status":"healthy","db":"ok","ezy":"ok","ts":...}
```
---
## GitHub Actions CI/CD
### Required Secrets (Settings → Secrets and variables → Actions)
| Secret | Description |
| ------------- | -------------------------------------------------- |
| `GHCR_TOKEN` | GitHub Personal Access Token with `write:packages` |
| `VPS_HOST` | VPS IP address |
| `VPS_USER` | SSH username |
| `VPS_SSH_KEY` | Private SSH key (`cat ~/.ssh/id_ed25519`) |
| `VPS_PORT` | SSH port (default: 22) |
### Deploy Flow
Every push to `main`:
1. CI runs lint + typecheck + tests + build
2. Docker image is built and pushed to `ghcr.io/aimpress/shumiland:<sha>`
3. SSH into VPS → `docker compose pull && docker compose up -d`
---
## Routine Operations
### Restart services
```bash
cd /opt/shumiland
docker compose -f docker-compose.prod.yml restart app
```
### View logs
```bash
# App logs (last 100 lines, follow)
docker compose -f docker-compose.prod.yml logs -f --tail=100 app
# Nginx access logs
docker compose -f docker-compose.prod.yml logs -f nginx
```
### Check service status
```bash
docker compose -f docker-compose.prod.yml ps
```
### Update to latest image manually
```bash
cd /opt/shumiland
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d
```
---
## Database Backup & Restore
### Manual backup
```bash
docker compose -f docker-compose.prod.yml exec postgres \
pg_dump -U shumiland shumiland | gzip > /tmp/manual-backup-$(date +%Y%m%d).sql.gz
```
### Automated backups
The `pg_backup` service runs daily at 03:00 UTC. Backups are stored in `./backups/` and older than 14 days are deleted automatically.
```bash
# List existing backups
ls -lh /opt/shumiland/backups/
```
### Restore from backup
```bash
# Stop the app (keep postgres running)
docker compose -f docker-compose.prod.yml stop app
# Restore
gunzip -c /opt/shumiland/backups/<filename>.sql.gz | \
docker compose -f docker-compose.prod.yml exec -T postgres \
psql -U shumiland shumiland
# Start app
docker compose -f docker-compose.prod.yml start app
```
---
## SSL Certificate Renewal
Certbot renews automatically via the certbot container. To renew manually:
```bash
docker compose -f docker-compose.prod.yml run --rm certbot renew
docker compose -f docker-compose.prod.yml restart nginx
```
---
## Troubleshooting
### App fails to start
```bash
# Check logs
docker compose -f docker-compose.prod.yml logs app
# Common causes:
# - Missing .env.production variable → check all required vars are set
# - DB not ready → check postgres health: docker compose ps
# - Port 3000 conflict → check nothing else runs on port 3000
```
### nginx returns 502 Bad Gateway
```bash
# Check if app container is running
docker compose -f docker-compose.prod.yml ps
# Check app logs for startup errors
docker compose -f docker-compose.prod.yml logs --tail=50 app
```
### Database connection error
```bash
# Test DB connectivity
docker compose -f docker-compose.prod.yml exec postgres \
psql -U shumiland -c "SELECT 1"
# Check DATABASE_URL in .env.production matches POSTGRES_PASSWORD
```
### Tariffs not syncing
```bash
# Manual sync
curl -X POST https://shumiland.com.ua/api/tariffs/sync \
-H "Authorization: Bearer <SYNC_SECRET>" -v
# Check EZY_ACTIVITY and EZY_PARTNER_KEY are set
docker compose -f docker-compose.prod.yml exec app env | grep EZY
```

26
eslint.config.mjs Normal file
View file

@ -0,0 +1,26 @@
import { FlatCompat } from '@eslint/eslintrc'
import tsPlugin from '@typescript-eslint/eslint-plugin'
import path from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const compat = new FlatCompat({
baseDirectory: __dirname,
})
/** @type {import('eslint').Linter.Config[]} */
const eslintConfig = [
...compat.extends('next/core-web-vitals'),
{
plugins: { '@typescript-eslint': tsPlugin },
rules: {
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'warn',
'no-console': ['warn', { allow: ['warn', 'error'] }],
},
},
]
export default eslintConfig

0
migrations/.gitkeep Normal file
View file

19
next.config.ts Normal file
View file

@ -0,0 +1,19 @@
import { withPayload } from '@payloadcms/next/withPayload'
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
output: 'standalone',
reactStrictMode: true,
images: {
remotePatterns: [
{
protocol: 'http',
hostname: 'localhost',
port: '3000',
pathname: '/media/**',
},
],
},
}
export default withPayload(nextConfig)

36
nginx.conf Normal file
View file

@ -0,0 +1,36 @@
server {
listen 80;
server_name shumiland.com.ua www.shumiland.com.ua;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name shumiland.com.ua www.shumiland.com.ua;
ssl_certificate /etc/letsencrypt/live/shumiland.com.ua/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/shumiland.com.ua/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
client_max_body_size 20M;
location / {
proxy_pass http://app:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}

76
package.json Normal file
View file

@ -0,0 +1,76 @@
{
"name": "shumiland-site",
"version": "0.1.0",
"private": true,
"packageManager": "pnpm@11.0.9",
"engines": {
"node": ">=20.0.0",
"pnpm": ">=11.0.0"
},
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"typecheck": "tsc --noEmit",
"lint": "next lint",
"format": "prettier --write .",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"payload": "payload",
"prepare": "husky",
"seed": "tsx src/seed.ts"
},
"lint-staged": {
"*.{ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{js,mjs,cjs,json,md,css}": [
"prettier --write"
]
},
"dependencies": {
"@payloadcms/db-postgres": "^3.33.0",
"@payloadcms/next": "^3.33.0",
"@payloadcms/richtext-lexical": "^3.33.0",
"@react-email/components": "^1.0.12",
"cyrillic-to-translit-js": "^3.2.1",
"graphql": "^16.9.0",
"next": "^15.3.2",
"payload": "^3.33.0",
"pino": "^9.6.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-email": "^6.1.1",
"resend": "^6.12.3",
"sharp": "^0.33.5",
"zod": "^3.24.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@types/node": "^20.14.10",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.2",
"@types/supertest": "^6.0.2",
"@typescript-eslint/eslint-plugin": "^8.32.0",
"@typescript-eslint/parser": "^8.32.0",
"@vitejs/plugin-react": "^4.4.1",
"@vitest/coverage-v8": "^3.1.4",
"dotenv": "^17.4.2",
"eslint": "^9.28.0",
"eslint-config-next": "^15.3.2",
"husky": "^9.1.7",
"lint-staged": "^15.5.0",
"pino-pretty": "^13.0.0",
"postcss": "^8.5.3",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.12",
"supertest": "^7.1.0",
"tailwindcss": "^4.1.6",
"tsx": "^4.21.0",
"typescript": "^5.8.3",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.1.4"
}
}

72
payload.config.ts Normal file
View file

@ -0,0 +1,72 @@
import { buildConfig } from 'payload'
import { postgresAdapter } from '@payloadcms/db-postgres'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import sharp from 'sharp'
import path from 'path'
import { fileURLToPath } from 'url'
import { Users } from './src/collections/Users'
import { Media } from './src/collections/Media'
import { Pages } from './src/collections/Pages'
import { BlogPosts } from './src/collections/BlogPosts'
import { Categories } from './src/collections/Categories'
import { Tags } from './src/collections/Tags'
import { Tariffs } from './src/collections/Tariffs'
import { Leads } from './src/collections/Leads'
import { Orders } from './src/collections/Orders'
import { HomePage } from './src/globals/HomePage'
import { CheckoutPage } from './src/globals/CheckoutPage'
import { ThankYouPage } from './src/globals/ThankYouPage'
import { Header } from './src/globals/Header'
import { Footer } from './src/globals/Footer'
import { SiteSettings } from './src/globals/SiteSettings'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
const siteURL = process.env['NEXT_PUBLIC_SITE_URL'] ?? 'http://localhost:3000'
export default buildConfig({
secret: process.env['PAYLOAD_SECRET']!,
serverURL: siteURL,
db: postgresAdapter({
pool: {
connectionString: process.env['DATABASE_URL']!,
},
push: false,
migrationDir: path.resolve(dirname, 'migrations'),
}),
editor: lexicalEditor(),
sharp,
collections: [Users, Media, Pages, BlogPosts, Categories, Tags, Tariffs, Leads, Orders],
globals: [HomePage, CheckoutPage, ThankYouPage, Header, Footer, SiteSettings],
admin: {
user: 'users',
livePreview: {
url: siteURL,
collections: ['pages'],
globals: [
'home-page',
'checkout-page',
'thank-you-page',
'header',
'footer',
'site-settings',
],
},
},
cors: [siteURL],
typescript: {
outputFile: path.resolve(dirname, 'src/payload-types.ts'),
},
telemetry: false,
})

10330
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

8
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,8 @@
allowBuilds:
esbuild: true
sharp: true
unrs-resolver: true
onlyBuiltDependencies:
- esbuild
- sharp
- unrs-resolver

5
postcss.config.mjs Normal file
View file

@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
}

0
src/access/.gitkeep Normal file
View file

3
src/access/isAdmin.ts Normal file
View file

@ -0,0 +1,3 @@
import type { Access } from 'payload'
export const isAdmin: Access = ({ req: { user } }) => user?.role === 'admin'

View file

@ -0,0 +1,4 @@
import type { Access } from 'payload'
export const isAdminOrEditor: Access = ({ req: { user } }) =>
user?.role === 'admin' || user?.role === 'editor'

View file

@ -0,0 +1,6 @@
import type { Access } from 'payload'
export const isAuthenticatedOrPublished: Access = ({ req: { user } }) => {
if (user) return true
return { _status: { equals: 'published' } }
}

View file

@ -0,0 +1,14 @@
import React from 'react'
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: {
template: '%s | Shumiland',
default: 'Shumiland',
},
description: 'Shumiland — coming soon.',
}
export default function FrontendLayout({ children }: { children: React.ReactNode }) {
return children
}

View file

@ -0,0 +1,8 @@
export default function HomePage() {
return (
<main>
<h1>Shumiland</h1>
<p>Coming soon.</p>
</main>
)
}

View file

@ -0,0 +1,12 @@
import { NotFoundPage } from '@payloadcms/next/views'
import configPromise from '@payload-config'
import { importMap } from '../importMap.js'
type Args = {
params: Promise<{ segments: string[] }>
searchParams: Promise<{ [key: string]: string | string[] }>
}
export default async function NotFound({ params, searchParams }: Args) {
return NotFoundPage({ config: configPromise, importMap, params, searchParams })
}

View file

@ -0,0 +1,17 @@
import type { Metadata } from 'next'
import { RootPage, generatePageMetadata } from '@payloadcms/next/views'
import configPromise from '@payload-config'
import { importMap } from '../importMap.js'
type Args = {
params: Promise<{ segments: string[] }>
searchParams: Promise<{ [key: string]: string | string[] }>
}
export async function generateMetadata({ params, searchParams }: Args): Promise<Metadata> {
return generatePageMetadata({ config: configPromise, params, searchParams })
}
export default async function Page({ params, searchParams }: Args) {
return RootPage({ config: configPromise, importMap, params, searchParams })
}

View file

@ -0,0 +1,6 @@
// This file is auto-generated by Payload CMS.
// Run `pnpm payload generate:importmap` to regenerate after adding components.
// See: https://payloadcms.com/docs/configuration/overview#import-map
/** @type {import('payload').ImportMap} */
export const importMap = {}

View file

@ -0,0 +1,16 @@
import {
REST_DELETE,
REST_GET,
REST_OPTIONS,
REST_PATCH,
REST_POST,
REST_PUT,
} from '@payloadcms/next/routes'
import configPromise from '@payload-config'
export const GET = REST_GET(configPromise)
export const POST = REST_POST(configPromise)
export const PUT = REST_PUT(configPromise)
export const PATCH = REST_PATCH(configPromise)
export const DELETE = REST_DELETE(configPromise)
export const OPTIONS = REST_OPTIONS(configPromise)

View file

@ -0,0 +1,9 @@
import React from 'react'
export const metadata = {
title: 'Shumiland Admin',
}
export default function PayloadLayout({ children }: { children: React.ReactNode }) {
return children
}

View file

@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
import { verifyHMAC, normalizePhone } from '@/lib/binotel'
import { logger } from '@/lib/logger'
const SECRET = process.env['BINOTEL_HMAC_SECRET']
export async function POST(req: NextRequest): Promise<NextResponse> {
if (!SECRET) {
logger.error('BINOTEL_HMAC_SECRET is not configured')
return NextResponse.json({ error: 'Service misconfigured' }, { status: 500 })
}
const body = await req.text()
const signature = req.headers.get('x-binotel-signature') ?? ''
if (!verifyHMAC(body, signature, SECRET as string)) {
logger.warn({ signature }, 'Binotel HMAC verification failed')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
let data: unknown
try {
data = JSON.parse(body)
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
const externalNumber = (data as Record<string, unknown>)['externalNumber']
if (typeof externalNumber !== 'string') {
return NextResponse.json({ ok: true })
}
const normalized = normalizePhone(externalNumber)
if (!normalized) {
return NextResponse.json({ ok: true })
}
try {
const payload = await getPayload({ config })
const { docs } = await payload.find({
collection: 'leads',
where: { phone: { equals: normalized } },
limit: 1,
overrideAccess: true,
})
const lead = docs[0]
if (lead) {
await payload.update({
collection: 'leads',
id: lead.id,
data: { lastCallAt: new Date().toISOString() },
overrideAccess: true,
})
logger.info({ leadId: lead.id }, 'Updated lead lastCallAt from Binotel')
}
} catch (err) {
logger.error({ err }, 'Binotel webhook DB error')
}
return NextResponse.json({ ok: true })
}

View file

@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server'
import { timingSafeEqual } from 'crypto'
import { syncTariffs } from '@/lib/syncTariffs'
import { logger } from '@/lib/logger'
const CRON_SECRET = process.env['CRON_SECRET'] ?? ''
function safeCompare(a: string, b: string): boolean {
const aBuf = Buffer.from(a)
const bBuf = Buffer.from(b)
if (aBuf.length !== bBuf.length) return false
return timingSafeEqual(aBuf, bBuf)
}
export async function GET(req: NextRequest): Promise<NextResponse> {
const auth = req.headers.get('authorization') ?? ''
if (!CRON_SECRET || !safeCompare(auth, `Bearer ${CRON_SECRET}`)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const result = await syncTariffs()
return NextResponse.json({ ok: true, ...result })
} catch (err) {
logger.error({ err }, 'Cron tariffs sync failed')
return NextResponse.json({ error: 'Sync failed' }, { status: 500 })
}
}

View file

@ -0,0 +1,39 @@
import { NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
async function checkDb(): Promise<'ok' | 'error'> {
try {
const payload = await getPayload({ config })
await payload.db.pool.query('SELECT 1')
return 'ok'
} catch {
return 'error'
}
}
async function checkEzy(): Promise<'ok' | 'error'> {
const activity = process.env['EZY_ACTIVITY']
if (!activity) return 'error'
try {
const ctrl = new AbortController()
const t = setTimeout(() => ctrl.abort(), 5_000)
const res = await fetch(
`https://ezy.com.ua/ipay/default/get-partner-tariff?activity=${activity}`,
{ method: 'POST', signal: ctrl.signal }
)
clearTimeout(t)
return res.ok ? 'ok' : 'error'
} catch {
return 'error'
}
}
export async function GET() {
const [db, ezy] = await Promise.all([checkDb(), checkEzy()])
const status = db === 'ok' && ezy === 'ok' ? 'healthy' : 'degraded'
return NextResponse.json(
{ status, db, ezy, ts: Date.now() },
{ status: status === 'healthy' ? 200 : 503 }
)
}

View file

@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getPayload } from 'payload'
import config from '@payload-config'
import { checkRateLimit } from '@/lib/rateLimit'
import { sendLeadAlert as sendTelegramAlert } from '@/lib/telegram'
import { sendLeadAlert as sendEmailAlert } from '@/lib/resend'
import { logger } from '@/lib/logger'
const LeadSchema = z.object({
name: z.string().min(1).max(100),
phone: z
.string()
.min(9)
.max(20)
.regex(/^[+\d\s\-().]+$/, 'Invalid phone number'),
email: z.string().email().optional(),
formSource: z.string().min(1).max(100),
utmSource: z.string().max(200).optional(),
utmMedium: z.string().max(200).optional(),
utmCampaign: z.string().max(200).optional(),
utmContent: z.string().max(200).optional(),
utmTerm: z.string().max(200).optional(),
gclid: z.string().max(200).optional(),
})
export async function POST(req: NextRequest): Promise<NextResponse> {
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'
if (!checkRateLimit(ip)) {
logger.warn({ ip }, 'Rate limit exceeded for /api/leads')
return NextResponse.json({ error: 'Too many requests' }, { status: 429 })
}
let body: unknown
try {
body = await req.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
const parsed = LeadSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.flatten() },
{ status: 422 }
)
}
const data = parsed.data
try {
const payload = await getPayload({ config })
await payload.create({
collection: 'leads',
data: {
name: data.name,
phone: data.phone,
email: data.email,
formSource: data.formSource,
utmSource: data.utmSource,
utmMedium: data.utmMedium,
utmCampaign: data.utmCampaign,
utmContent: data.utmContent,
utmTerm: data.utmTerm,
gclid: data.gclid,
status: 'new',
},
overrideAccess: true,
})
} catch (err) {
logger.error({ err }, 'Failed to save lead to DB')
return NextResponse.json({ error: 'Internal error' }, { status: 500 })
}
const alertData = {
name: data.name,
phone: data.phone,
email: data.email,
formSource: data.formSource,
utmSource: data.utmSource,
}
void Promise.allSettled([sendTelegramAlert(alertData), sendEmailAlert(alertData)])
return NextResponse.json({ ok: true }, { status: 201 })
}

View file

@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from 'next/server'
import { timingSafeEqual } from 'crypto'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
import { logger } from '@/lib/logger'
const SECRET = process.env['REVALIDATE_SECRET'] ?? ''
function safeCompare(a: string, b: string): boolean {
const aBuf = Buffer.from(a)
const bBuf = Buffer.from(b)
if (aBuf.length !== bBuf.length) return false
return timingSafeEqual(aBuf, bBuf)
}
const RevalidateSchema = z.object({
slug: z.string().max(200).optional(),
collection: z.string().max(100).optional(),
global: z.string().max(100).optional(),
})
export async function POST(req: NextRequest): Promise<NextResponse> {
const auth = req.headers.get('authorization') ?? ''
if (!SECRET || !safeCompare(auth, `Bearer ${SECRET}`)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
let body: unknown
try {
body = await req.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
const parsed = RevalidateSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })
}
const { slug, collection, global: globalSlug } = parsed.data
try {
if (globalSlug) {
revalidatePath('/', 'layout')
logger.info({ global: globalSlug }, 'Revalidated layout for global change')
} else if (slug) {
revalidatePath(`/${slug}`)
logger.info({ slug, collection }, 'Revalidated path')
} else if (collection) {
revalidatePath('/', 'layout')
logger.info({ collection }, 'Revalidated layout for collection change')
}
return NextResponse.json({ revalidated: true })
} catch (err) {
logger.error({ err }, 'Revalidation failed')
return NextResponse.json({ error: 'Revalidation failed' }, { status: 500 })
}
}

View file

@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server'
import { timingSafeEqual } from 'crypto'
import { syncTariffs } from '@/lib/syncTariffs'
import { logger } from '@/lib/logger'
const SECRET = process.env['SYNC_SECRET'] ?? ''
function safeCompare(a: string, b: string): boolean {
const aBuf = Buffer.from(a)
const bBuf = Buffer.from(b)
if (aBuf.length !== bBuf.length) return false
return timingSafeEqual(aBuf, bBuf)
}
export async function POST(req: NextRequest): Promise<NextResponse> {
const auth = req.headers.get('authorization') ?? ''
if (!SECRET || !safeCompare(auth, `Bearer ${SECRET}`)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const result = await syncTariffs()
return NextResponse.json({ ok: true, ...result })
} catch (err) {
logger.error({ err }, 'Tariffs sync failed')
return NextResponse.json({ error: 'Sync failed' }, { status: 500 })
}
}

View file

@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getPayload } from 'payload'
import config from '@payload-config'
import { createPayment } from '@/lib/ezy'
import { sendOrderAlert } from '@/lib/telegram'
import { logger } from '@/lib/logger'
const CheckoutSchema = z.object({
email: z.string().email(),
items: z
.array(
z.object({
tariff: z.string().min(1),
count: z
.string()
.regex(/^\d+$/)
.transform(Number)
.refine((n) => n >= 1 && n <= 10),
})
)
.min(1)
.max(20),
})
export async function POST(req: NextRequest): Promise<NextResponse> {
let body: unknown
try {
body = await req.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
const parsed = CheckoutSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.flatten() },
{ status: 422 }
)
}
const { email, items } = parsed.data
const ezyItems = items.map((i) => ({ tariff: i.tariff, count: String(i.count) }))
let monobankUrl: string
try {
const result = await createPayment(email, ezyItems)
monobankUrl = result.url
} catch (err) {
logger.error({ err }, 'ezy createPayment failed')
return NextResponse.json({ error: 'Payment service unavailable' }, { status: 502 })
}
const orderItems = items.map((i) => ({ tariffId: i.tariff, count: i.count }))
try {
const payload = await getPayload({ config })
await payload.create({
collection: 'orders',
data: {
email,
items: orderItems,
amount: 0,
monobankUrl,
status: 'redirected_to_payment',
ezyActivity: process.env['EZY_ACTIVITY'] ?? '',
},
overrideAccess: true,
})
} catch (err) {
logger.error({ err }, 'Failed to save order — continuing with redirect')
}
void sendOrderAlert({ email, items: orderItems, amount: 0 })
return NextResponse.json({ url: monobankUrl })
}

View file

@ -0,0 +1,76 @@
import { NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
import { getTariffs } from '@/lib/ezy'
import { logger } from '@/lib/logger'
export const dynamic = 'force-dynamic'
export async function GET(): Promise<NextResponse> {
try {
const ezyTariffs = await getTariffs()
const payload = await getPayload({ config })
const { docs: dbTariffs } = await payload.find({
collection: 'tariffs',
where: { visible: { equals: true } },
limit: 100,
overrideAccess: true,
})
const merged = ezyTariffs
.map((t) => {
const dbRecord = dbTariffs.find((d) => d.ezy_id === t.id)
if (!dbRecord) return null
return {
id: t.id,
name: dbRecord.display_name ?? t.name,
price: t.price,
categoryTag: dbRecord.category_tag,
description: dbRecord.description,
image: dbRecord.image,
icon: dbRecord.icon,
sort: dbRecord.sort ?? 0,
}
})
.filter(<T>(v: T | null): v is T => v !== null)
.sort((a, b) => a.sort - b.sort)
return NextResponse.json({ tariffs: merged })
} catch (err) {
logger.error({ err }, 'Failed to get tariffs from ezy — trying DB fallback')
try {
const payload = await getPayload({ config })
const { docs } = await payload.find({
collection: 'tariffs',
where: { visible: { equals: true } },
limit: 100,
overrideAccess: true,
})
if (docs.length === 0) {
return NextResponse.json({ error: 'Tariffs unavailable' }, { status: 503 })
}
const fallback = docs
.map((d) => ({
id: d.ezy_id,
name: d.display_name ?? d.last_synced_name,
price: d.last_synced_price,
categoryTag: d.category_tag,
description: d.description,
image: d.image,
icon: d.icon,
sort: d.sort ?? 0,
stale: true,
}))
.sort((a, b) => a.sort - b.sort)
return NextResponse.json({ tariffs: fallback, warning: 'Prices may be outdated' })
} catch (dbErr) {
logger.error({ dbErr }, 'DB fallback also failed')
return NextResponse.json({ error: 'Service unavailable' }, { status: 503 })
}
}
}

17
src/app/globals.css Normal file
View file

@ -0,0 +1,17 @@
@import 'tailwindcss';
:root {
/* Brand colours — to be defined in the Figma-to-code phase */
/* --color-primary: ; */
/* --color-primary-foreground: ; */
/* --color-secondary: ; */
/* --color-secondary-foreground: ; */
/* --color-accent: ; */
/* --color-accent-foreground: ; */
/* --color-background: ; */
/* --color-foreground: ; */
/* --color-muted: ; */
/* --color-muted-foreground: ; */
/* --color-border: ; */
/* --color-destructive: ; */
}

14
src/app/layout.tsx Normal file
View file

@ -0,0 +1,14 @@
import React from 'react'
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Shumiland',
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="uk">
<body>{children}</body>
</html>
)
}

0
src/blocks/.gitkeep Normal file
View file

21
src/blocks/CTA.ts Normal file
View file

@ -0,0 +1,21 @@
import type { Block } from 'payload'
export const CTABlock: Block = {
slug: 'cta',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'subtitle', type: 'text' },
{ name: 'ctaLabel', type: 'text', required: true },
{ name: 'ctaHref', type: 'text', required: true },
{
name: 'variant',
type: 'select',
defaultValue: 'primary',
options: [
{ label: 'Primary', value: 'primary' },
{ label: 'Secondary', value: 'secondary' },
{ label: 'Dark', value: 'dark' },
],
},
],
}

17
src/blocks/Features.ts Normal file
View file

@ -0,0 +1,17 @@
import type { Block } from 'payload'
export const FeaturesBlock: Block = {
slug: 'features',
fields: [
{ name: 'title', type: 'text' },
{
name: 'items',
type: 'array',
fields: [
{ name: 'icon', type: 'text' },
{ name: 'title', type: 'text', required: true },
{ name: 'description', type: 'text' },
],
},
],
}

16
src/blocks/Gallery.ts Normal file
View file

@ -0,0 +1,16 @@
import type { Block } from 'payload'
export const GalleryBlock: Block = {
slug: 'gallery',
fields: [
{ name: 'title', type: 'text' },
{
name: 'images',
type: 'array',
fields: [
{ name: 'image', type: 'upload', relationTo: 'media', required: true },
{ name: 'caption', type: 'text' },
],
},
],
}

22
src/blocks/Hero.ts Normal file
View file

@ -0,0 +1,22 @@
import type { Block } from 'payload'
export const HeroBlock: Block = {
slug: 'hero',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'subtitle', type: 'textarea' },
{ name: 'ctaLabel', type: 'text' },
{ name: 'ctaHref', type: 'text' },
{
name: 'backgroundType',
type: 'select',
defaultValue: 'image',
options: [
{ label: 'Image', value: 'image' },
{ label: 'Video', value: 'video' },
],
},
{ name: 'backgroundImage', type: 'upload', relationTo: 'media' },
{ name: 'backgroundVideoUrl', type: 'text' },
],
}

10
src/blocks/ImageBlock.ts Normal file
View file

@ -0,0 +1,10 @@
import type { Block } from 'payload'
export const ImageBlock: Block = {
slug: 'imageBlock',
fields: [
{ name: 'image', type: 'upload', relationTo: 'media', required: true },
{ name: 'caption', type: 'text' },
{ name: 'fullWidth', type: 'checkbox', defaultValue: false },
],
}

23
src/blocks/LeadForm.ts Normal file
View file

@ -0,0 +1,23 @@
import type { Block } from 'payload'
export const LeadFormBlock: Block = {
slug: 'leadForm',
fields: [
{ name: 'title', type: 'text' },
{ name: 'subtitle', type: 'text' },
{
name: 'formSource',
type: 'text',
required: true,
label: 'Ідентифікатор форми (для аналітики)',
},
{ name: 'showPhone', type: 'checkbox', defaultValue: true },
{ name: 'showEmail', type: 'checkbox', defaultValue: false },
{ name: 'ctaLabel', type: 'text', defaultValue: 'Відправити' },
{
name: 'successMessage',
type: 'text',
defaultValue: "Дякуємо! Ми зв'яжемося з вами найближчим часом.",
},
],
}

View file

@ -0,0 +1,19 @@
import type { Block } from 'payload'
export const LocationsTeaserBlock: Block = {
slug: 'locationsTeaser',
fields: [
{ name: 'title', type: 'text' },
{
name: 'locations',
type: 'array',
fields: [
{ name: 'name', type: 'text' },
{ name: 'description', type: 'textarea' },
{ name: 'image', type: 'upload', relationTo: 'media' },
{ name: 'href', type: 'text' },
{ name: 'ctaLabel', type: 'text' },
],
},
],
}

9
src/blocks/NewsBlock.ts Normal file
View file

@ -0,0 +1,9 @@
import type { Block } from 'payload'
export const NewsBlock: Block = {
slug: 'newsBlock',
fields: [
{ name: 'title', type: 'text', defaultValue: 'Новини' },
{ name: 'limit', type: 'number', defaultValue: 3, min: 1, max: 12 },
],
}

View file

@ -0,0 +1,10 @@
import type { Block } from 'payload'
export const NewsletterFormBlock: Block = {
slug: 'newsletterForm',
fields: [
{ name: 'title', type: 'text' },
{ name: 'subtitle', type: 'text' },
{ name: 'ctaLabel', type: 'text', defaultValue: 'Підписатися' },
],
}

View file

@ -0,0 +1,11 @@
import type { Block } from 'payload'
export const PricingBlock: Block = {
slug: 'pricingBlock',
fields: [
{ name: 'title', type: 'text' },
{ name: 'subtitle', type: 'text' },
{ name: 'showOnlyVisible', type: 'checkbox', defaultValue: true },
{ name: 'ctaLabel', type: 'text', defaultValue: 'Купити квиток' },
],
}

7
src/blocks/RichText.ts Normal file
View file

@ -0,0 +1,7 @@
import type { Block } from 'payload'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
export const RichTextBlock: Block = {
slug: 'richText',
fields: [{ name: 'content', type: 'richText', required: true, editor: lexicalEditor({}) }],
}

10
src/blocks/VideoBlock.ts Normal file
View file

@ -0,0 +1,10 @@
import type { Block } from 'payload'
export const VideoBlock: Block = {
slug: 'videoBlock',
fields: [
{ name: 'videoUrl', type: 'text', required: true, label: 'YouTube або Vimeo URL' },
{ name: 'caption', type: 'text' },
{ name: 'autoplay', type: 'checkbox', defaultValue: false },
],
}

0
src/collections/.gitkeep Normal file
View file

View file

@ -0,0 +1,55 @@
import type { CollectionConfig } from 'payload'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { isAdminOrEditor } from '@/access/isAdminOrEditor'
import { isAuthenticatedOrPublished } from '@/access/isAuthenticatedOrPublished'
import { slugifyBeforeChange } from '@/hooks/slugify'
import { revalidateAfterChange } from '@/hooks/revalidatePath'
export const BlogPosts: CollectionConfig = {
slug: 'blog-posts',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'slug', 'status', 'publishedAt', 'updatedAt'],
},
versions: { drafts: true },
access: {
read: isAuthenticatedOrPublished,
create: isAdminOrEditor,
update: isAdminOrEditor,
delete: isAdminOrEditor,
},
hooks: {
beforeChange: [slugifyBeforeChange],
afterChange: [revalidateAfterChange],
},
fields: [
{ name: 'title', type: 'text', required: true },
{
name: 'slug',
type: 'text',
unique: true,
index: true,
admin: { readOnly: true, position: 'sidebar' },
},
{
name: 'publishedAt',
type: 'date',
admin: { position: 'sidebar', date: { pickerAppearance: 'dayAndTime' } },
},
{ name: 'hero', type: 'upload', relationTo: 'media' },
{ name: 'body', type: 'richText', editor: lexicalEditor({}) },
{ name: 'excerpt', type: 'textarea' },
{ name: 'categories', type: 'relationship', relationTo: 'categories', hasMany: true },
{ name: 'tags', type: 'relationship', relationTo: 'tags', hasMany: true },
{
name: 'status',
type: 'select',
defaultValue: 'draft',
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
],
admin: { position: 'sidebar' },
},
],
}

View file

@ -0,0 +1,17 @@
import type { CollectionConfig } from 'payload'
import { isAdminOrEditor } from '@/access/isAdminOrEditor'
export const Categories: CollectionConfig = {
slug: 'categories',
admin: { useAsTitle: 'name' },
access: {
read: () => true,
create: isAdminOrEditor,
update: isAdminOrEditor,
delete: isAdminOrEditor,
},
fields: [
{ name: 'name', type: 'text', required: true },
{ name: 'slug', type: 'text', unique: true, index: true },
],
}

47
src/collections/Leads.ts Normal file
View file

@ -0,0 +1,47 @@
import type { CollectionConfig } from 'payload'
import { isAdmin } from '@/access/isAdmin'
export const Leads: CollectionConfig = {
slug: 'leads',
admin: {
useAsTitle: 'name',
defaultColumns: ['name', 'phone', 'formSource', 'status', 'createdAt'],
},
access: {
read: isAdmin,
create: isAdmin,
update: isAdmin,
delete: isAdmin,
},
fields: [
{ name: 'name', type: 'text', required: true },
{ name: 'phone', type: 'text', required: true },
{ name: 'email', type: 'email' },
{ name: 'formSource', type: 'text', required: true, label: 'Джерело форми' },
{ name: 'utmSource', type: 'text' },
{ name: 'utmMedium', type: 'text' },
{ name: 'utmCampaign', type: 'text' },
{ name: 'utmContent', type: 'text' },
{ name: 'utmTerm', type: 'text' },
{ name: 'gclid', type: 'text' },
{
name: 'status',
type: 'select',
defaultValue: 'new',
options: [
{ label: 'New', value: 'new' },
{ label: 'Contacted', value: 'contacted' },
{ label: 'Qualified', value: 'qualified' },
{ label: 'Closed', value: 'closed' },
],
admin: { position: 'sidebar' },
},
{ name: 'notes', type: 'textarea' },
{
name: 'lastCallAt',
type: 'date',
label: 'Last Call At',
admin: { readOnly: true, date: { pickerAppearance: 'dayAndTime' } },
},
],
}

17
src/collections/Media.ts Normal file
View file

@ -0,0 +1,17 @@
import type { CollectionConfig } from 'payload'
import { isAdminOrEditor } from '@/access/isAdminOrEditor'
export const Media: CollectionConfig = {
slug: 'media',
upload: true,
admin: {
useAsTitle: 'alt',
},
access: {
read: () => true,
create: isAdminOrEditor,
update: isAdminOrEditor,
delete: isAdminOrEditor,
},
fields: [{ name: 'alt', type: 'text', required: true, label: 'Alt text' }],
}

37
src/collections/Orders.ts Normal file
View file

@ -0,0 +1,37 @@
import type { CollectionConfig } from 'payload'
import { isAdmin } from '@/access/isAdmin'
export const Orders: CollectionConfig = {
slug: 'orders',
admin: {
useAsTitle: 'email',
defaultColumns: ['email', 'amount', 'status', 'createdAt'],
},
access: {
read: isAdmin,
create: isAdmin,
update: isAdmin,
delete: isAdmin,
},
fields: [
{ name: 'email', type: 'email', required: true },
{
name: 'items',
type: 'array',
fields: [
{ name: 'tariffId', type: 'text', required: true },
{ name: 'count', type: 'number', required: true, min: 1 },
],
},
{ name: 'amount', type: 'number', required: true },
{ name: 'monobankUrl', type: 'text', required: true, admin: { readOnly: true } },
{
name: 'status',
type: 'select',
defaultValue: 'redirected_to_payment',
options: [{ label: 'Redirected to payment', value: 'redirected_to_payment' }],
admin: { readOnly: true, position: 'sidebar' },
},
{ name: 'ezyActivity', type: 'text', label: 'ezy Activity ID', admin: { readOnly: true } },
],
}

82
src/collections/Pages.ts Normal file
View file

@ -0,0 +1,82 @@
import type { CollectionConfig } from 'payload'
import { isAdminOrEditor } from '@/access/isAdminOrEditor'
import { isAuthenticatedOrPublished } from '@/access/isAuthenticatedOrPublished'
import { slugifyBeforeChange } from '@/hooks/slugify'
import { revalidateAfterChange } from '@/hooks/revalidatePath'
import { HeroBlock } from '@/blocks/Hero'
import { LocationsTeaserBlock } from '@/blocks/LocationsTeaser'
import { FeaturesBlock } from '@/blocks/Features'
import { NewsBlock } from '@/blocks/NewsBlock'
import { NewsletterFormBlock } from '@/blocks/NewsletterForm'
import { GalleryBlock } from '@/blocks/Gallery'
import { RichTextBlock } from '@/blocks/RichText'
import { ImageBlock } from '@/blocks/ImageBlock'
import { VideoBlock } from '@/blocks/VideoBlock'
import { LeadFormBlock } from '@/blocks/LeadForm'
import { PricingBlock } from '@/blocks/PricingBlock'
import { CTABlock } from '@/blocks/CTA'
export const Pages: CollectionConfig = {
slug: 'pages',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'slug', 'status', 'updatedAt'],
},
versions: { drafts: true },
access: {
read: isAuthenticatedOrPublished,
create: isAdminOrEditor,
update: isAdminOrEditor,
delete: isAdminOrEditor,
},
hooks: {
beforeChange: [slugifyBeforeChange],
afterChange: [revalidateAfterChange],
},
fields: [
{ name: 'title', type: 'text', required: true },
{
name: 'slug',
type: 'text',
unique: true,
index: true,
admin: { readOnly: true, position: 'sidebar' },
},
{
name: 'status',
type: 'select',
defaultValue: 'draft',
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
],
admin: { position: 'sidebar' },
},
{
name: 'layout',
type: 'blocks',
blocks: [
HeroBlock,
LocationsTeaserBlock,
FeaturesBlock,
NewsBlock,
NewsletterFormBlock,
GalleryBlock,
RichTextBlock,
ImageBlock,
VideoBlock,
LeadFormBlock,
PricingBlock,
CTABlock,
],
},
{
name: 'meta',
type: 'group',
fields: [
{ name: 'metaTitle', type: 'text' },
{ name: 'metaDescription', type: 'textarea' },
],
},
],
}

17
src/collections/Tags.ts Normal file
View file

@ -0,0 +1,17 @@
import type { CollectionConfig } from 'payload'
import { isAdminOrEditor } from '@/access/isAdminOrEditor'
export const Tags: CollectionConfig = {
slug: 'tags',
admin: { useAsTitle: 'name' },
access: {
read: () => true,
create: isAdminOrEditor,
update: isAdminOrEditor,
delete: isAdminOrEditor,
},
fields: [
{ name: 'name', type: 'text', required: true },
{ name: 'slug', type: 'text', unique: true, index: true },
],
}

View file

@ -0,0 +1,78 @@
import type { CollectionConfig, FieldAccess } from 'payload'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { isAdmin } from '@/access/isAdmin'
const adminFieldAccess: FieldAccess = ({ req: { user } }) => user?.role === 'admin'
export const Tariffs: CollectionConfig = {
slug: 'tariffs',
admin: {
useAsTitle: 'last_synced_name',
defaultColumns: [
'last_synced_name',
'display_name',
'category_tag',
'last_synced_price',
'visible',
],
},
access: {
read: () => true,
create: isAdmin,
update: isAdmin,
delete: isAdmin,
},
fields: [
{
name: 'ezy_id',
type: 'number',
required: true,
unique: true,
index: true,
admin: {
readOnly: true,
description: 'Встановлюється автоматично при синхронізації з ezy API',
},
access: { update: adminFieldAccess },
},
{ name: 'display_name', type: 'text', label: 'Назва для сайту (перевизначення)' },
{ name: 'description', type: 'richText', editor: lexicalEditor({}) },
{ name: 'image', type: 'upload', relationTo: 'media' },
{ name: 'icon', type: 'text', label: 'Emoji або назва іконки' },
{
name: 'category_tag',
type: 'select',
required: true,
options: [
{ label: 'Dyno', value: 'dyno' },
{ label: 'Dyvolis', value: 'dyvolis' },
{ label: 'Maze', value: 'maze' },
{ label: 'Combo', value: 'combo' },
{ label: 'Family', value: 'family' },
],
},
{ name: 'sort', type: 'number', defaultValue: 0, admin: { position: 'sidebar' } },
{ name: 'visible', type: 'checkbox', defaultValue: true, admin: { position: 'sidebar' } },
{
name: 'last_synced_name',
type: 'text',
label: 'Назва з ezy API',
admin: { readOnly: true, description: 'Оновлюється автоматично при синхронізації' },
access: { update: adminFieldAccess },
},
{
name: 'last_synced_price',
type: 'number',
label: 'Ціна з ezy API (грн)',
admin: { readOnly: true },
access: { update: adminFieldAccess },
},
{
name: 'last_synced_at',
type: 'date',
label: 'Дата синхронізації',
admin: { readOnly: true, date: { pickerAppearance: 'dayAndTime' } },
access: { update: adminFieldAccess },
},
],
}

30
src/collections/Users.ts Normal file
View file

@ -0,0 +1,30 @@
import type { CollectionConfig } from 'payload'
import { isAdmin } from '@/access/isAdmin'
import { isAdminOrEditor } from '@/access/isAdminOrEditor'
export const Users: CollectionConfig = {
slug: 'users',
auth: true,
admin: {
useAsTitle: 'email',
},
access: {
read: isAdminOrEditor,
create: isAdmin,
update: isAdmin,
delete: isAdmin,
},
fields: [
{ name: 'name', type: 'text', required: true },
{
name: 'role',
type: 'select',
required: true,
defaultValue: 'editor',
options: [
{ label: 'Admin', value: 'admin' },
{ label: 'Editor', value: 'editor' },
],
},
],
}

0
src/emails/.gitkeep Normal file
View file

70
src/emails/LeadAlert.tsx Normal file
View file

@ -0,0 +1,70 @@
import {
Body,
Container,
Head,
Heading,
Html,
Preview,
Section,
Text,
} from '@react-email/components'
interface LeadAlertEmailProps {
name: string
phone: string
email?: string
formSource: string
utmSource?: string
submittedAt: string
}
export function LeadAlertEmail({
name,
phone,
email,
formSource,
utmSource,
submittedAt,
}: LeadAlertEmailProps) {
return (
<Html>
<Head />
<Preview>Новий лід: {name}</Preview>
<Body style={{ fontFamily: 'sans-serif', backgroundColor: '#f4f4f5' }}>
<Container
style={{
maxWidth: 480,
margin: '32px auto',
backgroundColor: '#fff',
padding: 24,
borderRadius: 8,
}}
>
<Heading style={{ fontSize: 20, marginBottom: 16 }}>Новий лід з сайту Shumiland</Heading>
<Section>
<Text>
<strong>Ім&apos;я:</strong> {name}
</Text>
<Text>
<strong>Телефон:</strong> {phone}
</Text>
{email && (
<Text>
<strong>Email:</strong> {email}
</Text>
)}
<Text>
<strong>Форма:</strong> {formSource}
</Text>
{utmSource && (
<Text>
<strong>UTM Source:</strong> {utmSource}
</Text>
)}
<Text style={{ color: '#6b7280', fontSize: 12 }}>Отримано: {submittedAt}</Text>
</Section>
</Container>
</Body>
</Html>
)
}

0
src/globals/.gitkeep Normal file
View file

View file

@ -0,0 +1,15 @@
import type { GlobalConfig } from 'payload'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { isAdminOrEditor } from '@/access/isAdminOrEditor'
import { revalidateGlobalAfterChange } from '@/hooks/revalidatePath'
export const CheckoutPage: GlobalConfig = {
slug: 'checkout-page',
access: { read: () => true, update: isAdminOrEditor },
hooks: { afterChange: [revalidateGlobalAfterChange] },
fields: [
{ name: 'title', type: 'text' },
{ name: 'instructions', type: 'richText', editor: lexicalEditor({}) },
{ name: 'terms', type: 'richText', editor: lexicalEditor({}) },
],
}

48
src/globals/Footer.ts Normal file
View file

@ -0,0 +1,48 @@
import type { GlobalConfig } from 'payload'
import { isAdminOrEditor } from '@/access/isAdminOrEditor'
import { revalidateGlobalAfterChange } from '@/hooks/revalidatePath'
export const Footer: GlobalConfig = {
slug: 'footer',
access: { read: () => true, update: isAdminOrEditor },
hooks: { afterChange: [revalidateGlobalAfterChange] },
fields: [
{ name: 'logo', type: 'upload', relationTo: 'media' },
{ name: 'logoAlt', type: 'text' },
{
name: 'navLinks',
type: 'array',
fields: [
{ name: 'label', type: 'text' },
{ name: 'href', type: 'text' },
],
},
{
name: 'contacts',
type: 'group',
fields: [
{ name: 'phone', type: 'text' },
{ name: 'email', type: 'email' },
{ name: 'address', type: 'text' },
],
},
{
name: 'socials',
type: 'array',
fields: [
{
name: 'platform',
type: 'select',
options: [
{ label: 'Instagram', value: 'instagram' },
{ label: 'Facebook', value: 'facebook' },
{ label: 'YouTube', value: 'youtube' },
{ label: 'TikTok', value: 'tiktok' },
],
},
{ name: 'url', type: 'text' },
],
},
{ name: 'copyrightText', type: 'text' },
],
}

22
src/globals/Header.ts Normal file
View file

@ -0,0 +1,22 @@
import type { GlobalConfig } from 'payload'
import { isAdminOrEditor } from '@/access/isAdminOrEditor'
import { revalidateGlobalAfterChange } from '@/hooks/revalidatePath'
export const Header: GlobalConfig = {
slug: 'header',
access: { read: () => true, update: isAdminOrEditor },
hooks: { afterChange: [revalidateGlobalAfterChange] },
fields: [
{ name: 'logo', type: 'upload', relationTo: 'media' },
{ name: 'logoAlt', type: 'text' },
{
name: 'navLinks',
type: 'array',
fields: [
{ name: 'label', type: 'text' },
{ name: 'href', type: 'text' },
{ name: 'openInNewTab', type: 'checkbox', defaultValue: false },
],
},
],
}

59
src/globals/HomePage.ts Normal file
View file

@ -0,0 +1,59 @@
import type { GlobalConfig } from 'payload'
import { isAdminOrEditor } from '@/access/isAdminOrEditor'
import { revalidateGlobalAfterChange } from '@/hooks/revalidatePath'
export const HomePage: GlobalConfig = {
slug: 'home-page',
access: { read: () => true, update: isAdminOrEditor },
hooks: { afterChange: [revalidateGlobalAfterChange] },
fields: [
{
name: 'hero',
type: 'group',
fields: [
{ name: 'title', type: 'text' },
{ name: 'subtitle', type: 'textarea' },
{ name: 'ctaLabel', type: 'text' },
{ name: 'ctaHref', type: 'text' },
{ name: 'backgroundVideo', type: 'text' },
{ name: 'backgroundImage', type: 'upload', relationTo: 'media' },
],
},
{
name: 'locations',
type: 'array',
fields: [
{ name: 'name', type: 'text' },
{ name: 'shortDesc', type: 'textarea' },
{ name: 'image', type: 'upload', relationTo: 'media' },
{ name: 'href', type: 'text' },
],
},
{
name: 'features',
type: 'array',
fields: [
{ name: 'icon', type: 'text' },
{ name: 'title', type: 'text' },
{ name: 'description', type: 'text' },
],
},
{
name: 'news',
type: 'group',
fields: [
{ name: 'title', type: 'text' },
{ name: 'limit', type: 'number', defaultValue: 3, min: 1, max: 12 },
],
},
{
name: 'newsletter',
type: 'group',
fields: [
{ name: 'title', type: 'text' },
{ name: 'subtitle', type: 'text' },
{ name: 'ctaLabel', type: 'text' },
],
},
],
}

View file

@ -0,0 +1,18 @@
import type { GlobalConfig } from 'payload'
import { isAdminOrEditor } from '@/access/isAdminOrEditor'
import { revalidateGlobalAfterChange } from '@/hooks/revalidatePath'
export const SiteSettings: GlobalConfig = {
slug: 'site-settings',
access: { read: () => true, update: isAdminOrEditor },
hooks: { afterChange: [revalidateGlobalAfterChange] },
fields: [
{ name: 'ga4Id', type: 'text', label: 'Google Analytics 4 Measurement ID' },
{ name: 'binotelId', type: 'text' },
{ name: 'telegramChatId', type: 'text' },
{ name: 'resendFrom', type: 'email', label: 'Від кого (Resend)' },
{ name: 'defaultMetaTitle', type: 'text' },
{ name: 'defaultMetaDescription', type: 'textarea' },
{ name: 'defaultOgImage', type: 'upload', relationTo: 'media' },
],
}

View file

@ -0,0 +1,16 @@
import type { GlobalConfig } from 'payload'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { isAdminOrEditor } from '@/access/isAdminOrEditor'
import { revalidateGlobalAfterChange } from '@/hooks/revalidatePath'
export const ThankYouPage: GlobalConfig = {
slug: 'thank-you-page',
access: { read: () => true, update: isAdminOrEditor },
hooks: { afterChange: [revalidateGlobalAfterChange] },
fields: [
{ name: 'title', type: 'text' },
{ name: 'message', type: 'richText', editor: lexicalEditor({}) },
{ name: 'contactPhone', type: 'text' },
{ name: 'contactEmail', type: 'email' },
],
}

0
src/hooks/.gitkeep Normal file
View file

View file

@ -0,0 +1,41 @@
import type { CollectionAfterChangeHook, GlobalAfterChangeHook } from 'payload'
import pino from 'pino'
const logger = pino({ name: 'revalidate' })
const SITE_URL = process.env['NEXT_PUBLIC_SITE_URL'] ?? 'http://localhost:3000'
const REVALIDATE_SECRET = process.env['REVALIDATE_SECRET'] ?? ''
type RevalidatePayload = {
slug?: string
collection?: string
global?: string
}
async function sendRevalidate(body: RevalidatePayload): Promise<void> {
try {
const res = await fetch(`${SITE_URL}/api/revalidate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${REVALIDATE_SECRET}`,
},
body: JSON.stringify(body),
})
if (!res.ok) {
logger.warn({ status: res.status, body }, 'Revalidate returned non-OK')
}
} catch (err) {
logger.error({ err, body }, 'Revalidate fetch failed')
}
}
export const revalidateAfterChange: CollectionAfterChangeHook = ({ doc, collection }) => {
void sendRevalidate({ slug: doc.slug as string | undefined, collection: collection.slug })
return doc
}
export const revalidateGlobalAfterChange: GlobalAfterChangeHook = ({ doc, global }) => {
void sendRevalidate({ global: global.slug })
return doc
}

26
src/hooks/slugify.ts Normal file
View file

@ -0,0 +1,26 @@
import type { CollectionBeforeChangeHook } from 'payload'
import CyrillicToTranslit from 'cyrillic-to-translit-js'
const cyrillicToTranslit = new CyrillicToTranslit()
function buildSlug(source: string): string {
const transliterated = cyrillicToTranslit.transform(source, '-')
return transliterated
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '')
.replace(/-{2,}/g, '-')
.replace(/^-|-$/g, '')
}
export const slugifyBeforeChange: CollectionBeforeChangeHook = ({ data, operation }) => {
if (operation === 'create' || !data.slug) {
if (data.title && typeof data.title === 'string') {
return {
...data,
slug: buildSlug(data.title),
}
}
}
return data
}

0
src/lib/.gitkeep Normal file
View file

19
src/lib/binotel.ts Normal file
View file

@ -0,0 +1,19 @@
import { createHmac, timingSafeEqual } from 'crypto'
export function verifyHMAC(payload: string, signature: string, secret: string): boolean {
const expected = createHmac('sha256', secret).update(payload).digest('hex')
const expectedBuf = Buffer.from(expected, 'utf8')
const signatureBuf = Buffer.from(signature, 'utf8')
if (expectedBuf.length !== signatureBuf.length) return false
return timingSafeEqual(expectedBuf, signatureBuf)
}
export function normalizePhone(input: string): string | null {
const digits = input.replace(/\D/g, '')
if (digits.startsWith('380') && digits.length === 12) return '+' + digits
if (digits.startsWith('38') && digits.length === 11) return '+' + digits
if (digits.startsWith('0') && digits.length === 10) return '+38' + digits
return null
}

95
src/lib/ezy.ts Normal file
View file

@ -0,0 +1,95 @@
import { z } from 'zod'
import { logger } from '@/lib/logger'
const BASE_URL = 'https://ezy.com.ua'
const EzyTariffSchema = z.object({
id: z.number(),
name: z.string(),
price: z.number(),
params: z.record(z.unknown()).optional(),
})
export type EzyTariff = z.infer<typeof EzyTariffSchema>
const EzyTariffsResponseSchema = z.array(EzyTariffSchema)
const EzyPaymentResponseSchema = z.object({
url: z.string().url(),
form: z.null().optional(),
})
type Cache<T> = { data: T; expiresAt: number }
let tariffsCache: Cache<EzyTariff[]> | null = null
async function fetchWithRetry(url: string, init: RequestInit, attempt = 0): Promise<Response> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 10_000)
try {
const res = await fetch(url, { ...init, signal: controller.signal })
return res
} catch (err) {
clearTimeout(timeout)
if (attempt < 2) {
await new Promise((r) => setTimeout(r, 200 * 2 ** attempt))
return fetchWithRetry(url, init, attempt + 1)
}
throw err
} finally {
clearTimeout(timeout)
}
}
export async function getTariffs(): Promise<EzyTariff[]> {
const now = Date.now()
if (tariffsCache !== null && now < tariffsCache.expiresAt) {
logger.debug('ezy getTariffs: cache hit')
return tariffsCache.data
}
const activity = process.env['EZY_ACTIVITY']
if (!activity) throw new Error('EZY_ACTIVITY env var is required')
const url = `${BASE_URL}/ipay/default/get-partner-tariff?activity=${activity}`
logger.info({ url }, 'ezy getTariffs: fetching')
const res = await fetchWithRetry(url, { method: 'POST' })
if (!res.ok) {
throw new Error(`ezy getTariffs failed with status ${res.status}`)
}
const json: unknown = await res.json()
const tariffs = EzyTariffsResponseSchema.parse(json)
tariffsCache = { data: tariffs, expiresAt: now + 5 * 60 * 1_000 }
logger.info({ count: tariffs.length }, 'ezy getTariffs: cached')
return tariffs
}
export async function createPayment(
email: string,
items: Array<{ tariff: string; count: string }>
): Promise<{ url: string }> {
const partnerKey = process.env['EZY_PARTNER_KEY']
if (!partnerKey) throw new Error('EZY_PARTNER_KEY env var is required')
const form = new FormData()
form.append('email', email)
form.append('order', JSON.stringify(items))
form.append('partner_key', partnerKey)
const url = `${BASE_URL}/ipay/pay/partner-pay`
logger.info({ itemCount: items.length }, 'ezy createPayment: posting')
const res = await fetchWithRetry(url, { method: 'POST', body: form })
if (!res.ok) {
throw new Error(`ezy createPayment failed with status ${res.status}`)
}
const json: unknown = await res.json()
const parsed = EzyPaymentResponseSchema.parse(json)
logger.info('ezy createPayment: success')
return { url: parsed.url }
}

6
src/lib/logger.ts Normal file
View file

@ -0,0 +1,6 @@
import pino from 'pino'
export const logger = pino({
name: 'shumiland',
level: process.env['LOG_LEVEL'] ?? 'info',
})

34
src/lib/rateLimit.ts Normal file
View file

@ -0,0 +1,34 @@
type Bucket = {
tokens: number
lastRefill: number
}
const buckets = new Map<string, Bucket>()
const MAX_TOKENS = 5
const REFILL_INTERVAL_MS = 15 * 60 * 1000
const REFILL_AMOUNT = 5
export function checkRateLimit(ip: string): boolean {
const now = Date.now()
let bucket = buckets.get(ip)
if (!bucket) {
bucket = { tokens: MAX_TOKENS - 1, lastRefill: now }
buckets.set(ip, bucket)
return true
}
const elapsed = now - bucket.lastRefill
if (elapsed >= REFILL_INTERVAL_MS) {
bucket.tokens = REFILL_AMOUNT
bucket.lastRefill = now
}
if (bucket.tokens <= 0) {
return false
}
bucket.tokens -= 1
return true
}

41
src/lib/resend.ts Normal file
View file

@ -0,0 +1,41 @@
import { Resend } from 'resend'
import { render } from '@react-email/components'
import { LeadAlertEmail } from '@/emails/LeadAlert'
import { logger } from '@/lib/logger'
const resend = new Resend(process.env['RESEND_API_KEY'])
const FROM = process.env['RESEND_FROM'] ?? 'noreply@shumiland.ua'
const MANAGER_EMAILS = (process.env['MANAGER_EMAILS'] ?? '').split(',').filter(Boolean)
export type LeadAlertData = {
name: string
phone: string
email?: string
formSource: string
utmSource?: string
}
export async function sendLeadAlert(lead: LeadAlertData): Promise<void> {
if (MANAGER_EMAILS.length === 0) {
logger.warn('MANAGER_EMAILS not configured — skipping email alert')
return
}
const html = await render(
LeadAlertEmail({
...lead,
submittedAt: new Date().toLocaleString('uk-UA', { timeZone: 'Europe/Kyiv' }),
})
)
try {
await resend.emails.send({
from: FROM,
to: MANAGER_EMAILS,
subject: `Новий лід: ${lead.name} (${lead.formSource})`,
html,
})
} catch (err) {
logger.error({ err }, 'Resend sendLeadAlert error')
}
}

84
src/lib/syncTariffs.ts Normal file
View file

@ -0,0 +1,84 @@
import { getPayload } from 'payload'
import config from '@payload-config'
import { getTariffs } from '@/lib/ezy'
import { logger } from '@/lib/logger'
export type SyncResult = {
synced: number
created: number
hidden: number
}
export async function syncTariffs(): Promise<SyncResult> {
const payload = await getPayload({ config })
const ezyTariffs = await getTariffs()
let synced = 0
let created = 0
for (const tariff of ezyTariffs) {
const { docs } = await payload.find({
collection: 'tariffs',
where: { ezy_id: { equals: tariff.id } },
limit: 1,
overrideAccess: true,
})
const existing = docs[0]
if (existing) {
await payload.update({
collection: 'tariffs',
id: existing.id,
data: {
last_synced_name: tariff.name,
last_synced_price: tariff.price,
last_synced_at: new Date().toISOString(),
} as never,
overrideAccess: true,
})
synced++
} else {
await payload.create({
collection: 'tariffs',
data: {
ezy_id: tariff.id,
last_synced_name: tariff.name,
last_synced_price: tariff.price,
last_synced_at: new Date().toISOString(),
category_tag: 'dyno',
visible: true,
sort: 0,
} as never,
overrideAccess: true,
})
created++
}
}
const ezyIds = new Set(ezyTariffs.map((t) => t.id))
const { docs: visibleDbTariffs } = await payload.find({
collection: 'tariffs',
where: { visible: { equals: true } },
limit: 500,
overrideAccess: true,
})
let hidden = 0
for (const dbTariff of visibleDbTariffs) {
const ezyId = (dbTariff as unknown as { ezy_id?: number }).ezy_id
if (ezyId !== undefined && !ezyIds.has(ezyId)) {
await payload.update({
collection: 'tariffs',
id: dbTariff.id,
data: { visible: false } as never,
overrideAccess: true,
})
hidden++
}
}
logger.info({ synced, created, hidden }, 'Tariffs sync complete')
return { synced, created, hidden }
}

64
src/lib/telegram.ts Normal file
View file

@ -0,0 +1,64 @@
import { logger } from '@/lib/logger'
const BOT_TOKEN = process.env['TELEGRAM_BOT_TOKEN'] ?? ''
const CHAT_ID = process.env['TELEGRAM_CHAT_ID'] ?? ''
async function sendMessage(text: string): Promise<void> {
if (!BOT_TOKEN || !CHAT_ID) {
logger.warn('Telegram not configured — skipping alert')
return
}
try {
const res = await fetch(`https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: CHAT_ID,
text,
parse_mode: 'HTML',
}),
})
if (!res.ok) {
logger.warn({ status: res.status }, 'Telegram sendMessage failed')
}
} catch (err) {
logger.error({ err }, 'Telegram sendMessage error')
}
}
export type LeadAlertData = {
name: string
phone: string
email?: string
formSource: string
utmSource?: string
}
export type OrderAlertData = {
email: string
items: Array<{ tariffId: string; count: number }>
amount: number
}
export async function sendLeadAlert(lead: LeadAlertData): Promise<void> {
const lines = [
'🔔 <b>Новий лід</b>',
`👤 ${lead.name}`,
`📱 ${lead.phone}`,
lead.email ? `📧 ${lead.email}` : null,
`📋 Форма: ${lead.formSource}`,
lead.utmSource ? `🔗 UTM: ${lead.utmSource}` : null,
]
await sendMessage(lines.filter(Boolean).join('\n'))
}
export async function sendOrderAlert(order: OrderAlertData): Promise<void> {
const itemList = order.items.map((i) => ` • тариф ${i.tariffId} × ${i.count}`).join('\n')
const text = [
'🎟 <b>Новий замовлення</b>',
`📧 ${order.email}`,
itemList,
`💰 ${order.amount} грн`,
].join('\n')
await sendMessage(text)
}

21
src/lib/utm.ts Normal file
View file

@ -0,0 +1,21 @@
const UTM_WHITELIST = [
'utm_source',
'utm_medium',
'utm_campaign',
'utm_content',
'utm_term',
'gclid',
] as const
export type UtmParams = Partial<Record<(typeof UTM_WHITELIST)[number], string>>
export function parseQuery(searchParams: URLSearchParams): UtmParams {
const result: UtmParams = {}
for (const key of UTM_WHITELIST) {
const value = searchParams.get(key)
if (value) {
result[key] = value
}
}
return result
}

58
src/seed.ts Normal file
View file

@ -0,0 +1,58 @@
import 'dotenv/config'
import { getPayload } from 'payload'
import config from '../payload.config'
async function seed(): Promise<void> {
const payload = await getPayload({ config })
const { totalDocs } = await payload.find({
collection: 'users',
limit: 1,
overrideAccess: true,
})
if (totalDocs === 0) {
await payload.create({
collection: 'users',
data: {
email: 'admin@shumiland.ua',
password: 'changeMe123!',
name: 'Admin',
role: 'admin',
},
overrideAccess: true,
})
console.log('Created admin user: admin@shumiland.ua / changeMe123!')
} else {
console.log('Users already exist, skipping user seed.')
}
const globalSlugs = [
'home-page',
'checkout-page',
'thank-you-page',
'header',
'footer',
'site-settings',
] as const
for (const slug of globalSlugs) {
try {
await payload.updateGlobal({
slug,
data: {} as never,
overrideAccess: true,
})
console.log(`Initialized global: ${slug}`)
} catch (err) {
console.warn(`Could not initialize global ${slug}:`, err)
}
}
process.exit(0)
}
seed().catch((err) => {
console.error('Seed failed:', err)
process.exit(1)
})

View file

@ -0,0 +1,5 @@
declare module 'cyrillic-to-translit-js' {
export default class CyrillicToTranslit {
transform(str: string, sep?: string): string
}
}

0
tests/api/.gitkeep Normal file
View file

View file

@ -0,0 +1,127 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextRequest } from 'next/server'
import { createHmac } from 'crypto'
const SECRET = 'test-hmac-secret' // matches vitest.setup.ts
const mockPayloadFind = vi.fn()
const mockPayloadUpdate = vi.fn()
const mockGetPayload = vi.fn(() => ({
find: mockPayloadFind,
update: mockPayloadUpdate,
}))
vi.mock('payload', () => ({ getPayload: mockGetPayload }))
vi.mock('@payload-config', () => ({ default: {} }))
function makeSignature(body: string): string {
return createHmac('sha256', SECRET).update(body).digest('hex')
}
function makeRequest(body: string, signature: string): NextRequest {
return new NextRequest('http://localhost/api/binotel/webhook', {
method: 'POST',
body,
headers: {
'content-type': 'text/plain',
'x-binotel-signature': signature,
},
})
}
let POST: (req: NextRequest) => Promise<Response>
beforeEach(async () => {
vi.resetModules()
mockPayloadFind.mockReset()
mockPayloadUpdate.mockReset()
vi.stubEnv('BINOTEL_HMAC_SECRET', SECRET)
const mod = await import('@/app/api/binotel/webhook/route')
POST = mod.POST
})
describe('POST /api/binotel/webhook', () => {
it('returns 401 for invalid HMAC signature', async () => {
const req = makeRequest('{"externalNumber":"0501234567"}', 'bad-signature')
const res = await POST(req)
expect(res.status).toBe(401)
})
it('returns 400 for valid signature but invalid JSON body', async () => {
const body = 'not json'
const req = makeRequest(body, makeSignature(body))
const res = await POST(req)
expect(res.status).toBe(400)
})
it('returns ok:true when externalNumber is missing from payload', async () => {
const body = JSON.stringify({ event: 'call' })
const req = makeRequest(body, makeSignature(body))
const res = await POST(req)
const json = await res.json()
expect(res.status).toBe(200)
expect(json.ok).toBe(true)
expect(mockPayloadFind).not.toHaveBeenCalled()
})
it('returns ok:true when externalNumber is not a string', async () => {
const body = JSON.stringify({ externalNumber: 12345 })
const req = makeRequest(body, makeSignature(body))
const res = await POST(req)
expect((await res.json()).ok).toBe(true)
})
it('returns ok:true when phone cannot be normalized', async () => {
const body = JSON.stringify({ externalNumber: 'INVALID' })
const req = makeRequest(body, makeSignature(body))
const res = await POST(req)
expect((await res.json()).ok).toBe(true)
expect(mockPayloadFind).not.toHaveBeenCalled()
})
it('updates lead lastCallAt when matching lead is found', async () => {
mockPayloadFind.mockResolvedValueOnce({ docs: [{ id: 'lead-42' }] })
mockPayloadUpdate.mockResolvedValueOnce({})
const body = JSON.stringify({ externalNumber: '0501234567' })
const req = makeRequest(body, makeSignature(body))
const res = await POST(req)
expect(res.status).toBe(200)
expect(mockPayloadFind).toHaveBeenCalledWith(
expect.objectContaining({
collection: 'leads',
where: { phone: { equals: '+380501234567' } },
})
)
expect(mockPayloadUpdate).toHaveBeenCalledWith(
expect.objectContaining({
collection: 'leads',
id: 'lead-42',
data: expect.objectContaining({ lastCallAt: expect.any(String) }),
})
)
})
it('returns ok:true when no matching lead is found (no update)', async () => {
mockPayloadFind.mockResolvedValueOnce({ docs: [] })
const body = JSON.stringify({ externalNumber: '0501234567' })
const req = makeRequest(body, makeSignature(body))
const res = await POST(req)
expect(res.status).toBe(200)
expect(mockPayloadUpdate).not.toHaveBeenCalled()
})
it('returns ok:true even when the DB call throws', async () => {
mockPayloadFind.mockRejectedValueOnce(new Error('DB down'))
const body = JSON.stringify({ externalNumber: '0501234567' })
const req = makeRequest(body, makeSignature(body))
const res = await POST(req)
expect(res.status).toBe(200)
expect((await res.json()).ok).toBe(true)
})
})

21
tests/api/health.test.ts Normal file
View file

@ -0,0 +1,21 @@
import { describe, it, expect } from 'vitest'
import { GET } from '@/app/api/health/route'
describe('GET /api/health', () => {
it('returns 200 with status:healthy', async () => {
const res = await GET()
expect(res.status).toBe(200)
const json = await res.json()
expect(json.status).toBe('healthy')
})
it('includes a numeric timestamp', async () => {
const before = Date.now()
const res = await GET()
const after = Date.now()
const { ts } = await res.json()
expect(typeof ts).toBe('number')
expect(ts).toBeGreaterThanOrEqual(before)
expect(ts).toBeLessThanOrEqual(after)
})
})

118
tests/api/leads.test.ts Normal file
View file

@ -0,0 +1,118 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextRequest } from 'next/server'
const mockPayloadCreate = vi.fn()
const mockGetPayload = vi.fn(() => ({ create: mockPayloadCreate }))
vi.mock('payload', () => ({ getPayload: mockGetPayload }))
vi.mock('@payload-config', () => ({ default: {} }))
vi.mock('@/lib/telegram', () => ({ sendLeadAlert: vi.fn().mockResolvedValue(undefined) }))
vi.mock('@/lib/resend', () => ({ sendLeadAlert: vi.fn().mockResolvedValue(undefined) }))
const VALID_LEAD = {
name: 'Іван Іванов',
phone: '+380501234567',
formSource: 'hero-form',
}
function makeRequest(body: unknown, ip = '1.2.3.4'): NextRequest {
return new NextRequest('http://localhost/api/leads', {
method: 'POST',
body: typeof body === 'string' ? body : JSON.stringify(body),
headers: {
'content-type': 'application/json',
'x-forwarded-for': ip,
},
})
}
let POST: (req: NextRequest) => Promise<Response>
beforeEach(async () => {
vi.resetModules()
mockPayloadCreate.mockReset()
// Re-import so rateLimit buckets are fresh
const mod = await import('@/app/api/leads/route')
POST = mod.POST
})
describe('POST /api/leads', () => {
it('returns 201 for a valid lead', async () => {
mockPayloadCreate.mockResolvedValueOnce({ id: 'lead-1' })
const res = await POST(makeRequest(VALID_LEAD))
expect(res.status).toBe(201)
expect((await res.json()).ok).toBe(true)
})
it('returns 400 for invalid JSON body', async () => {
const res = await POST(makeRequest('not-json'))
expect(res.status).toBe(400)
})
it('returns 422 when required fields are missing', async () => {
const res = await POST(makeRequest({ phone: '123' }))
expect(res.status).toBe(422)
const json = await res.json()
expect(json.error).toBe('Validation failed')
expect(json.details).toBeDefined()
})
it('returns 422 when name is empty string', async () => {
const res = await POST(makeRequest({ ...VALID_LEAD, name: '' }))
expect(res.status).toBe(422)
})
it('returns 422 when phone is too short', async () => {
const res = await POST(makeRequest({ ...VALID_LEAD, phone: '12345' }))
expect(res.status).toBe(422)
})
it('returns 422 when email is present but invalid', async () => {
const res = await POST(makeRequest({ ...VALID_LEAD, email: 'not-an-email' }))
expect(res.status).toBe(422)
})
it('accepts optional UTM params and email', async () => {
mockPayloadCreate.mockResolvedValueOnce({ id: 'lead-2' })
const res = await POST(
makeRequest({
...VALID_LEAD,
email: 'test@example.com',
utmSource: 'google',
utmMedium: 'cpc',
utmCampaign: 'summer',
utmContent: 'banner',
utmTerm: 'shoes',
gclid: 'abc123',
})
)
expect(res.status).toBe(201)
})
it('returns 500 when DB create throws', async () => {
mockPayloadCreate.mockRejectedValueOnce(new Error('DB error'))
const res = await POST(makeRequest(VALID_LEAD, '9.9.9.1'))
expect(res.status).toBe(500)
})
it('returns 429 after 5 requests from the same IP', async () => {
mockPayloadCreate.mockResolvedValue({ id: 'x' })
const ip = '5.5.5.5'
for (let i = 0; i < 5; i++) {
await POST(makeRequest(VALID_LEAD, ip))
}
const res = await POST(makeRequest(VALID_LEAD, ip))
expect(res.status).toBe(429)
})
it('uses "unknown" when x-forwarded-for header is absent', async () => {
mockPayloadCreate.mockResolvedValueOnce({ id: 'lead-3' })
const req = new NextRequest('http://localhost/api/leads', {
method: 'POST',
body: JSON.stringify(VALID_LEAD),
headers: { 'content-type': 'application/json' },
})
const res = await POST(req)
expect(res.status).toBe(201)
})
})

View file

@ -0,0 +1,102 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextRequest } from 'next/server'
const mockRevalidatePath = vi.fn()
vi.mock('next/cache', () => ({
revalidatePath: mockRevalidatePath,
revalidateTag: vi.fn(),
}))
const SECRET = 'test-revalidate-secret' // matches vitest.setup.ts
function makeRequest(body: unknown, token = SECRET): NextRequest {
return new NextRequest('http://localhost/api/revalidate', {
method: 'POST',
body: typeof body === 'string' ? body : JSON.stringify(body),
headers: {
'content-type': 'application/json',
authorization: `Bearer ${token}`,
},
})
}
let POST: (req: NextRequest) => Promise<Response>
beforeEach(async () => {
vi.resetModules()
mockRevalidatePath.mockReset()
vi.stubEnv('REVALIDATE_SECRET', SECRET)
const mod = await import('@/app/api/revalidate/route')
POST = mod.POST
})
describe('POST /api/revalidate', () => {
it('returns 401 when authorization header is missing', async () => {
const req = new NextRequest('http://localhost/api/revalidate', {
method: 'POST',
body: JSON.stringify({ slug: 'home' }),
headers: { 'content-type': 'application/json' },
})
const res = await POST(req)
expect(res.status).toBe(401)
})
it('returns 401 for wrong token', async () => {
const res = await POST(makeRequest({ slug: 'home' }, 'wrong-token'))
expect(res.status).toBe(401)
})
it('returns 401 when REVALIDATE_SECRET env is empty (no bypass)', async () => {
vi.stubEnv('REVALIDATE_SECRET', '')
vi.resetModules()
const mod = await import('@/app/api/revalidate/route')
const res = await mod.POST(makeRequest({ slug: 'home' }, ''))
expect(res.status).toBe(401)
})
it('returns 400 for invalid JSON body', async () => {
const req = new NextRequest('http://localhost/api/revalidate', {
method: 'POST',
body: 'not-json',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${SECRET}`,
},
})
const res = await POST(req)
expect(res.status).toBe(400)
})
it('revalidates a specific path when slug is provided', async () => {
const res = await POST(makeRequest({ slug: 'about-us' }))
expect(res.status).toBe(200)
expect(mockRevalidatePath).toHaveBeenCalledWith('/about-us')
expect((await res.json()).revalidated).toBe(true)
})
it('revalidates layout when global is provided', async () => {
const res = await POST(makeRequest({ global: 'header' }))
expect(res.status).toBe(200)
expect(mockRevalidatePath).toHaveBeenCalledWith('/', 'layout')
})
it('revalidates layout when collection is provided', async () => {
const res = await POST(makeRequest({ collection: 'posts' }))
expect(res.status).toBe(200)
expect(mockRevalidatePath).toHaveBeenCalledWith('/', 'layout')
})
it('returns revalidated:true and calls no revalidation when body has none of slug/collection/global', async () => {
const res = await POST(makeRequest({}))
expect(res.status).toBe(200)
expect(mockRevalidatePath).not.toHaveBeenCalled()
})
it('returns 500 when revalidatePath throws', async () => {
mockRevalidatePath.mockImplementationOnce(() => {
throw new Error('cache error')
})
const res = await POST(makeRequest({ slug: 'boom' }))
expect(res.status).toBe(500)
})
})

View file

@ -0,0 +1,116 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextRequest } from 'next/server'
const mockCreatePayment = vi.fn()
const mockPayloadCreate = vi.fn()
const mockGetPayload = vi.fn(() => ({ create: mockPayloadCreate }))
const mockSendOrderAlert = vi.fn().mockResolvedValue(undefined)
vi.mock('@/lib/ezy', () => ({ createPayment: mockCreatePayment }))
vi.mock('@/lib/telegram', () => ({ sendOrderAlert: mockSendOrderAlert }))
vi.mock('payload', () => ({ getPayload: mockGetPayload }))
vi.mock('@payload-config', () => ({ default: {} }))
const VALID_BODY = {
email: 'buyer@example.com',
items: [{ tariff: 'adult-2026', count: '2' }],
}
function makeRequest(body: unknown): NextRequest {
return new NextRequest('http://localhost/api/tickets/checkout', {
method: 'POST',
body: JSON.stringify(body),
headers: { 'content-type': 'application/json' },
})
}
let POST: (req: NextRequest) => Promise<Response>
beforeEach(async () => {
vi.resetModules()
mockCreatePayment.mockReset()
mockPayloadCreate.mockReset()
mockSendOrderAlert.mockReset()
const mod = await import('@/app/api/tickets/checkout/route')
POST = mod.POST
})
describe('POST /api/tickets/checkout', () => {
it('returns 400 for invalid JSON body', async () => {
const req = new NextRequest('http://localhost/api/tickets/checkout', {
method: 'POST',
body: 'not-json',
headers: { 'content-type': 'application/json' },
})
const res = await POST(req)
expect(res.status).toBe(400)
})
it('returns 422 when email is missing', async () => {
const res = await POST(makeRequest({ items: VALID_BODY.items }))
expect(res.status).toBe(422)
})
it('returns 422 when email is invalid', async () => {
const res = await POST(makeRequest({ ...VALID_BODY, email: 'not-email' }))
expect(res.status).toBe(422)
})
it('returns 422 when items array is empty', async () => {
const res = await POST(makeRequest({ ...VALID_BODY, items: [] }))
expect(res.status).toBe(422)
})
it('returns 422 when count is not a numeric string', async () => {
const res = await POST(makeRequest({ ...VALID_BODY, items: [{ tariff: 'x', count: 'abc' }] }))
expect(res.status).toBe(422)
})
it('returns 422 when count is 0 (below minimum)', async () => {
const res = await POST(makeRequest({ ...VALID_BODY, items: [{ tariff: 'x', count: '0' }] }))
expect(res.status).toBe(422)
})
it('returns 422 when count exceeds 10', async () => {
const res = await POST(makeRequest({ ...VALID_BODY, items: [{ tariff: 'x', count: '11' }] }))
expect(res.status).toBe(422)
})
it('returns 502 when ezy createPayment fails', async () => {
mockCreatePayment.mockRejectedValueOnce(new Error('ezy down'))
const res = await POST(makeRequest(VALID_BODY))
expect(res.status).toBe(502)
})
it('returns 200 with payment URL on success', async () => {
mockCreatePayment.mockResolvedValueOnce({ url: 'https://pay.ezy.com/abc' })
mockPayloadCreate.mockResolvedValueOnce({ id: 'order-1' })
const res = await POST(makeRequest(VALID_BODY))
expect(res.status).toBe(200)
const json = await res.json()
expect(json.url).toBe('https://pay.ezy.com/abc')
})
it('returns 200 even when DB order creation fails', async () => {
mockCreatePayment.mockResolvedValueOnce({ url: 'https://pay.ezy.com/xyz' })
mockPayloadCreate.mockRejectedValueOnce(new Error('DB error'))
const res = await POST(makeRequest(VALID_BODY))
expect(res.status).toBe(200)
expect((await res.json()).url).toBe('https://pay.ezy.com/xyz')
})
it('accepts items array with multiple entries', async () => {
mockCreatePayment.mockResolvedValueOnce({ url: 'https://pay.ezy.com/multi' })
mockPayloadCreate.mockResolvedValueOnce({})
const res = await POST(
makeRequest({
email: 'a@b.com',
items: [
{ tariff: 'adult', count: '2' },
{ tariff: 'child', count: '1' },
],
})
)
expect(res.status).toBe(200)
})
})

View file

@ -0,0 +1,122 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockGetTariffs = vi.fn()
const mockPayloadFind = vi.fn()
const mockGetPayload = vi.fn(() => ({ find: mockPayloadFind }))
vi.mock('@/lib/ezy', () => ({ getTariffs: mockGetTariffs }))
vi.mock('payload', () => ({ getPayload: mockGetPayload }))
vi.mock('@payload-config', () => ({ default: {} }))
const ezyTariffs = [
{ id: 1, name: 'Дорослий', price: 250 },
{ id: 2, name: 'Дитячий', price: 150 },
]
const dbTariffs = [
{ ezy_id: 1, display_name: 'Дорослий (UA)', category_tag: 'adult', sort: 0 },
{ ezy_id: 2, display_name: 'Дитячий (UA)', category_tag: 'child', sort: 1 },
]
let GET: () => Promise<Response>
beforeEach(async () => {
vi.resetModules()
mockGetTariffs.mockReset()
mockPayloadFind.mockReset()
const mod = await import('@/app/api/tickets/tariffs/route')
GET = mod.GET
})
describe('GET /api/tickets/tariffs', () => {
it('returns merged tariffs sorted by sort field', async () => {
mockGetTariffs.mockResolvedValueOnce(ezyTariffs)
mockPayloadFind.mockResolvedValueOnce({ docs: dbTariffs })
const res = await GET()
expect(res.status).toBe(200)
const { tariffs } = await res.json()
expect(tariffs).toHaveLength(2)
expect(tariffs[0].id).toBe(1)
expect(tariffs[0].name).toBe('Дорослий (UA)')
expect(tariffs[0].price).toBe(250)
})
it('filters out ezy tariffs with no matching DB record', async () => {
mockGetTariffs.mockResolvedValueOnce([...ezyTariffs, { id: 99, name: 'Unknown', price: 0 }])
mockPayloadFind.mockResolvedValueOnce({ docs: dbTariffs })
const res = await GET()
const { tariffs } = await res.json()
expect(tariffs).toHaveLength(2)
expect(tariffs.map((t: { id: number }) => t.id)).not.toContain(99)
})
it('uses db display_name over ezy name', async () => {
mockGetTariffs.mockResolvedValueOnce([{ id: 1, name: 'EZY Name', price: 300 }])
mockPayloadFind.mockResolvedValueOnce({
docs: [{ ezy_id: 1, display_name: 'DB Name', sort: 0 }],
})
const res = await GET()
const { tariffs } = await res.json()
expect(tariffs[0].name).toBe('DB Name')
})
it('falls back to DB tariffs when ezy throws, with warning', async () => {
mockGetTariffs.mockRejectedValueOnce(new Error('ezy down'))
mockPayloadFind.mockResolvedValueOnce({
docs: [
{
ezy_id: 1,
display_name: 'Adult',
last_synced_price: 250,
last_synced_name: 'Adult',
sort: 0,
},
],
})
const res = await GET()
expect(res.status).toBe(200)
const json = await res.json()
expect(json.warning).toMatch(/outdated/i)
expect(json.tariffs[0].stale).toBe(true)
})
it('returns 503 when ezy fails and DB is empty', async () => {
mockGetTariffs.mockRejectedValueOnce(new Error('ezy down'))
mockPayloadFind.mockResolvedValueOnce({ docs: [] })
const res = await GET()
expect(res.status).toBe(503)
})
it('returns 503 when both ezy and DB fail', async () => {
mockGetTariffs.mockRejectedValueOnce(new Error('ezy down'))
mockPayloadFind.mockRejectedValueOnce(new Error('DB down'))
const res = await GET()
expect(res.status).toBe(503)
const json = await res.json()
expect(json.error).toBeDefined()
})
it('returns tariffs sorted in ascending sort order', async () => {
mockGetTariffs.mockResolvedValueOnce([
{ id: 2, name: 'B', price: 100 },
{ id: 1, name: 'A', price: 200 },
])
mockPayloadFind.mockResolvedValueOnce({
docs: [
{ ezy_id: 1, display_name: 'A', sort: 0 },
{ ezy_id: 2, display_name: 'B', sort: 5 },
],
})
const res = await GET()
const { tariffs } = await res.json()
expect(tariffs[0].id).toBe(1)
expect(tariffs[1].id).toBe(2)
})
})

View file

@ -0,0 +1,166 @@
/**
* Tests for .claude/helpers/github-safe.js
*
* github-safe.js is an ES module, so we test it by running it as a subprocess
* via child_process. This mirrors real usage and avoids ESM/CJS interop issues.
*
* Run: node --test tests/helpers/github-safe.test.js
*
* Coverage gaps addressed:
* - Exit 1 when fewer than 2 args provided (usage guard)
* - Exit 1 for commands not in ALLOWED_COMMANDS
* - Body written to temp file for issue/pr comment/create with --body
* - Positional body for "issue comment <number> <body>" form
* - No temp file created when no body is present
* - Shell metacharacters in body don't cause injection (they go through temp file)
* - Temp file is cleaned up even when gh exits non-zero
* - Non-body commands forwarded directly (no temp file)
*
* NOTE: Tests that need `gh` stub it with a simple Node script so the suite
* runs without GitHub credentials.
*/
'use strict'
const { describe, it } = require('node:test')
const assert = require('node:assert/strict')
const { spawnSync, execFileSync } = require('node:child_process')
const fs = require('node:fs')
const path = require('node:path')
const os = require('node:os')
const SCRIPT = path.resolve(__dirname, '../../.claude/helpers/github-safe.js')
// Spawn github-safe.js with a fake $PATH that puts a stub `gh` first
function run(args, { ghStub = null, env = {} } = {}) {
let binDir = null
if (ghStub) {
binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gh-stub-'))
const stub = path.join(binDir, 'gh')
fs.writeFileSync(stub, ghStub, { mode: 0o755 })
}
const result = spawnSync(process.execPath, ['--input-type=module', SCRIPT, ...args], {
encoding: 'utf-8',
env: {
...process.env,
...(binDir ? { PATH: `${binDir}:${process.env.PATH}` } : {}),
...env,
},
})
if (binDir) fs.rmSync(binDir, { recursive: true, force: true })
return result
}
// A stub gh that just echos its args as JSON to stdout and exits 0
const ECHO_STUB = `#!/bin/sh\necho "$@"`
// A stub gh that exits 1 (simulates gh failure)
const FAIL_STUB = `#!/bin/sh\nexit 1`
describe('github-safe.js', () => {
// ── Usage guard ─────────────────────────────────────────────────────────────
describe('usage guard', () => {
it('exits 1 when no args provided', () => {
const r = run([])
assert.equal(r.status, 1, 'expected exit code 1')
})
it('exits 1 when only one arg provided', () => {
const r = run(['issue'])
assert.equal(r.status, 1)
})
it('prints usage text when no args', () => {
const r = run([])
assert.ok(r.stdout.includes('Usage'), `stdout: ${r.stdout}`)
})
})
// ── ALLOWED_COMMANDS allowlist ──────────────────────────────────────────────
describe('command allowlist', () => {
it('exits 1 for an unknown top-level gh command', () => {
const r = run(['badcmd', 'list'], { ghStub: ECHO_STUB })
assert.equal(r.status, 1)
assert.ok(r.stderr.includes('Refusing'), `stderr: ${r.stderr}`)
})
const allowed = ['issue', 'pr', 'repo', 'api', 'workflow', 'run', 'release', 'auth', 'gist']
for (const cmd of allowed) {
it(`allows the "${cmd}" command`, () => {
const r = run([cmd, 'list'], { ghStub: ECHO_STUB })
assert.notEqual(r.status, 1, `${cmd} should not be blocked`)
})
}
})
// ── Body handling ──────────────────────────────────────────────────────────
describe('body handling — temp file routing', () => {
it('passes --body-file for "issue comment <num> <body>" positional form', () => {
const r = run(['issue', 'comment', '42', 'hello world'], { ghStub: ECHO_STUB })
// gh stub echoes all args; --body-file must appear in the output
assert.ok(r.stdout.includes('--body-file'), `stdout: ${r.stdout}`)
})
it('passes --body-file for "issue create --title T --body B" form', () => {
const r = run(['issue', 'create', '--title', 'My Issue', '--body', 'Description here'], {
ghStub: ECHO_STUB,
})
assert.ok(r.stdout.includes('--body-file'), `stdout: ${r.stdout}`)
})
it('passes --body-file for "pr create --title T --body B" form', () => {
const r = run(['pr', 'create', '--title', 'My PR', '--body', 'PR body'], {
ghStub: ECHO_STUB,
})
assert.ok(r.stdout.includes('--body-file'), `stdout: ${r.stdout}`)
})
it('routes body with backticks through temp file (no shell interpretation)', () => {
const body = 'Code: `ls -la` and more `$(whoami)`'
const r = run(['issue', 'comment', '1', body], { ghStub: ECHO_STUB })
// If injection were happening, the shell would expand $() — instead we see --body-file
assert.ok(r.stdout.includes('--body-file'), `stdout: ${r.stdout}`)
})
it('does NOT create --body-file when no body is provided', () => {
const r = run(['issue', 'list'], { ghStub: ECHO_STUB })
assert.ok(
!r.stdout.includes('--body-file'),
`stdout should not contain --body-file: ${r.stdout}`
)
})
})
// ── Non-body commands forwarded directly ────────────────────────────────────
describe('passthrough for non-body commands', () => {
it('forwards "repo list" directly without --body-file', () => {
const r = run(['repo', 'list'], { ghStub: ECHO_STUB })
assert.ok(!r.stdout.includes('--body-file'))
})
it('forwards "workflow run" directly', () => {
const r = run(['workflow', 'run', 'deploy.yml'], { ghStub: ECHO_STUB })
assert.ok(!r.stdout.includes('--body-file'))
})
})
// ── Cleanup on failure ──────────────────────────────────────────────────────
describe('temp file cleanup', () => {
it('exits cleanly even when gh fails (no leftover temp files observed via fs)', () => {
// Capture the /tmp listing before and after — no .tmp file should remain
const tmpBefore = new Set(fs.readdirSync(os.tmpdir()).filter((f) => f.startsWith('gh-body-')))
const r = run(['issue', 'comment', '1', 'body text'], { ghStub: FAIL_STUB })
const tmpAfter = new Set(fs.readdirSync(os.tmpdir()).filter((f) => f.startsWith('gh-body-')))
const leaked = [...tmpAfter].filter((f) => !tmpBefore.has(f))
assert.deepEqual(leaked, [], `Temp files leaked: ${leaked.join(', ')}`)
})
})
})

View file

@ -0,0 +1,254 @@
/**
* Tests for .claude/helpers/hook-handler.cjs
*
* hook-handler.cjs is run as a subprocess (as Claude Code invokes it),
* so all tests spawn it via child_process and inspect stdout/stderr/exitCode.
*
* Run: node --test tests/helpers/hook-handler.test.js
*
* Coverage gaps addressed:
* - pre-bash: blocks known dangerous commands, allows safe commands
* - route: routes via router.routeTask, formats output table
* - post-edit: outputs [OK] Edit recorded
* - pre-task: outputs task routing info
* - post-task: outputs [OK] Task completed
* - session-restore: falls back gracefully when session module missing
* - session-end: outputs [OK] Session ended (or intelligence consolidation)
* - stats: warns when intelligence module unavailable
* - unknown command: passes through with [OK]
* - no command: prints usage
* - always exits 0 (hooks must never crash Claude Code)
* - safeRequire: silences noisy module output during require
* - runWithTimeout: resolves null on timeout (tested via session-restore latency)
* - stdin JSON: hook reads and parses stdin data
* - global safety timeout: process exits within 5s even if handler hangs
*/
'use strict'
const { describe, it } = require('node:test')
const assert = require('node:assert/strict')
const { spawnSync } = require('node:child_process')
const path = require('node:path')
const SCRIPT = path.resolve(__dirname, '../../.claude/helpers/hook-handler.cjs')
function run(args = [], { stdin = '', env = {} } = {}) {
return spawnSync(process.execPath, [SCRIPT, ...args], {
input: stdin,
encoding: 'utf-8',
timeout: 8000,
env: { ...process.env, ...env },
})
}
// ── Exit code guarantee ────────────────────────────────────────────────────────
describe('hook-handler.cjs — exit code', () => {
const commands = [
'route',
'pre-bash',
'post-edit',
'session-restore',
'session-end',
'pre-task',
'post-task',
'stats',
'unknown-cmd',
'',
]
for (const cmd of commands) {
it(`always exits 0 for command "${cmd || '(none)'}"`, () => {
const r = run(cmd ? [cmd] : [])
assert.equal(r.status, 0, `status: ${r.status}, stderr: ${r.stderr}`)
})
}
})
// ── Usage ─────────────────────────────────────────────────────────────────────
describe('no command', () => {
it('prints usage when no command is provided', () => {
const r = run([])
assert.ok(r.stdout.includes('Usage'), `stdout: ${r.stdout}`)
})
})
// ── Unknown command ───────────────────────────────────────────────────────────
describe('unknown command', () => {
it('outputs [OK] for an unrecognised command', () => {
const r = run(['some-future-hook'])
assert.ok(r.stdout.includes('[OK]'), `stdout: ${r.stdout}`)
})
})
// ── pre-bash ──────────────────────────────────────────────────────────────────
describe('pre-bash handler', () => {
it('blocks "rm -rf /" with exit 0 and BLOCKED output', () => {
// Danger detected → still exits 0 (process.exit(1) is called from within
// main() which is caught by the .catch() that always exits 0 — but
// the actual implementation calls process.exit(1) directly inside the handler.
// We test the stderr/stdout signal instead.
const r = run(['pre-bash'], { stdin: JSON.stringify({ command: 'rm -rf /' }) })
assert.ok(
r.stderr.includes('[BLOCKED]') || r.stdout.includes('[BLOCKED]'),
`Expected BLOCKED. stdout: ${r.stdout} stderr: ${r.stderr}`
)
})
it('outputs [OK] Command validated for a safe command', () => {
const r = run(['pre-bash'], { stdin: JSON.stringify({ command: 'ls -la' }) })
assert.ok(r.stdout.includes('[OK]'), `stdout: ${r.stdout}`)
})
it('blocks "format c:" (Windows-style destructive command)', () => {
const r = run(['pre-bash'], { stdin: JSON.stringify({ command: 'format c:' }) })
assert.ok(
r.stderr.includes('[BLOCKED]') || r.stdout.includes('[BLOCKED]'),
`stdout: ${r.stdout} stderr: ${r.stderr}`
)
})
it('blocks the fork bomb ":(){:|:&};:"', () => {
const r = run(['pre-bash'], { stdin: JSON.stringify({ command: ':(){:|:&};:' }) })
assert.ok(
r.stderr.includes('[BLOCKED]') || r.stdout.includes('[BLOCKED]'),
`stdout: ${r.stdout} stderr: ${r.stderr}`
)
})
})
// ── route handler ──────────────────────────────────────────────────────────────
describe('route handler', () => {
it('outputs a recommendation table with Agent row', () => {
const r = run(['route'], { stdin: JSON.stringify({ prompt: 'implement a new feature' }) })
assert.ok(r.stdout.includes('Agent:'), `stdout: ${r.stdout}`)
})
it('outputs Confidence row', () => {
const r = run(['route'], { stdin: JSON.stringify({ prompt: 'write tests for the module' }) })
assert.ok(r.stdout.includes('Confidence:'), `stdout: ${r.stdout}`)
})
it('routes "write tests" prompt to tester agent', () => {
const r = run(['route'], { stdin: JSON.stringify({ prompt: 'write tests for auth module' }) })
assert.ok(r.stdout.includes('tester'), `stdout: ${r.stdout}`)
})
it('falls back to coder for an unknown prompt', () => {
const r = run(['route'], { stdin: JSON.stringify({ prompt: 'xyzzy completely unknown task' }) })
assert.ok(r.stdout.includes('coder'), `stdout: ${r.stdout}`)
})
it('works when no prompt is provided (empty stdin)', () => {
const r = run(['route'], { stdin: '' })
// Should not crash — must exit 0 (already tested above) and emit some output
assert.ok(r.stdout.length > 0, 'expected some output')
})
})
// ── post-edit handler ──────────────────────────────────────────────────────────
describe('post-edit handler', () => {
it('outputs [OK] Edit recorded', () => {
const r = run(['post-edit'], { stdin: JSON.stringify({ file_path: 'src/index.js' }) })
assert.ok(r.stdout.includes('[OK]'), `stdout: ${r.stdout}`)
})
})
// ── pre-task handler ───────────────────────────────────────────────────────────
describe('pre-task handler', () => {
it('outputs task routing info', () => {
const r = run(['pre-task'], { stdin: JSON.stringify({ prompt: 'build the API' }) })
assert.ok(r.stdout.includes('[OK]') || r.stdout.includes('[INFO]'), `stdout: ${r.stdout}`)
})
})
// ── post-task handler ──────────────────────────────────────────────────────────
describe('post-task handler', () => {
it('outputs [OK] Task completed', () => {
const r = run(['post-task'])
assert.ok(r.stdout.includes('[OK]'), `stdout: ${r.stdout}`)
})
})
// ── session-restore handler ────────────────────────────────────────────────────
describe('session-restore handler', () => {
it('runs without error and produces some output', () => {
const r = run(['session-restore'])
assert.ok(r.stdout.length > 0 || r.stderr.length > 0, 'expected some output')
})
})
// ── session-end handler ────────────────────────────────────────────────────────
describe('session-end handler', () => {
it('runs without error and exits 0', () => {
const r = run(['session-end'])
assert.equal(r.status, 0)
})
})
// ── stats handler ──────────────────────────────────────────────────────────────
describe('stats handler', () => {
it('warns when intelligence module is unavailable', () => {
// In the test environment there's no built intelligence db — WARN is expected
const r = run(['stats'])
// Either warns or prints stats — both acceptable outcomes
assert.ok(
r.stdout.includes('[WARN]') || r.stdout.includes('patterns') || r.stdout.length > 0,
`stdout: ${r.stdout}`
)
})
})
// ── stdin JSON parsing ─────────────────────────────────────────────────────────
describe('stdin JSON hook data', () => {
it('reads tool_name (snake_case) from stdin JSON', () => {
const r = run(['route'], {
stdin: JSON.stringify({
tool_name: 'Bash',
tool_input: { command: 'ls' },
prompt: 'implement something',
}),
})
// If parsed correctly, routing runs; check for the recommendation table
assert.ok(r.stdout.includes('Agent:'), `stdout: ${r.stdout}`)
})
it('reads toolName (camelCase) from stdin JSON', () => {
const r = run(['route'], {
stdin: JSON.stringify({
toolName: 'Edit',
toolInput: { file_path: 'a.js' },
prompt: 'review code',
}),
})
assert.ok(r.stdout.includes('Agent:'), `stdout: ${r.stdout}`)
})
it('handles malformed JSON stdin gracefully (no crash)', () => {
const r = run(['pre-bash'], { stdin: '{ invalid json' })
assert.equal(r.status, 0)
})
})
// ── Integration: full pipeline event simulation ────────────────────────────────
describe('integration — simulated Claude Code hook sequence', () => {
it('session-restore → pre-task → post-edit → post-task → session-end all exit 0', () => {
const sequence = ['session-restore', 'pre-task', 'post-edit', 'post-task', 'session-end']
for (const cmd of sequence) {
const r = run([cmd], { stdin: JSON.stringify({ prompt: 'build feature X' }) })
assert.equal(r.status, 0, `${cmd} must exit 0, got ${r.status}. stderr: ${r.stderr}`)
}
})
})

View file

@ -0,0 +1,219 @@
/**
* Tests for .claude/helpers/memory.js
*
* Run: node --test tests/helpers/memory.test.js
*
* Coverage gaps addressed:
* - loadMemory: missing file, corrupted JSON, valid file
* - saveMemory: directory creation, JSON format
* - commands.get: with key, without key, missing key
* - commands.set: missing key arg, _updated timestamp, overwrite
* - commands.delete: missing key arg, non-existent key
* - commands.keys: filters _-prefixed meta keys
* - commands.clear: resets to empty object
* - Error handling: corrupted JSON masked silently
*/
'use strict'
const { describe, it, before, after, beforeEach } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const os = require('node:os')
// Helper to load the module with a custom CWD so MEMORY_FILE points to a tmp dir
function loadModule(cwd) {
// Clear require cache so MEMORY_DIR/MEMORY_FILE are re-evaluated each time
const modPath = require.resolve('../../.claude/helpers/memory.js')
delete require.cache[modPath]
const origCwd = process.cwd
process.cwd = () => cwd
try {
const mod = require('../../.claude/helpers/memory.js')
return mod
} finally {
process.cwd = origCwd
delete require.cache[modPath]
}
}
describe('memory.js', () => {
let tmpDir
let commands
let memoryFile
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mem-test-'))
memoryFile = path.join(tmpDir, '.claude-flow', 'data', 'memory.json')
})
after(() => {
fs.rmSync(tmpDir, { recursive: true, force: true })
})
beforeEach(() => {
// Reset memory file before each test
const dir = path.dirname(memoryFile)
if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true })
commands = loadModule(tmpDir)
})
// ── loadMemory ─────────────────────────────────────────────────────────────
describe('loadMemory (via commands.get)', () => {
it('returns {} when memory file does not exist', () => {
const result = commands.get(undefined)
assert.deepEqual(result, {})
})
it('returns {} and does not throw when file contains corrupted JSON', () => {
const dir = path.dirname(memoryFile)
fs.mkdirSync(dir, { recursive: true })
fs.writeFileSync(memoryFile, '{ not valid json !!!')
// Should not throw; silently returns {}
const result = commands.get(undefined)
assert.deepEqual(result, {})
})
it('parses and returns stored memory when file is valid JSON', () => {
const dir = path.dirname(memoryFile)
fs.mkdirSync(dir, { recursive: true })
fs.writeFileSync(memoryFile, JSON.stringify({ foo: 'bar' }, null, 2))
const result = commands.get('foo')
assert.equal(result, 'bar')
})
// EDGE CASE: empty file (0 bytes) should not crash
it('returns {} when file is empty', () => {
const dir = path.dirname(memoryFile)
fs.mkdirSync(dir, { recursive: true })
fs.writeFileSync(memoryFile, '')
const result = commands.get(undefined)
assert.deepEqual(result, {})
})
})
// ── saveMemory (via commands.set) ──────────────────────────────────────────
describe('saveMemory (via commands.set)', () => {
it('creates the data directory if it does not exist', () => {
commands.set('k', 'v')
assert.ok(fs.existsSync(path.dirname(memoryFile)))
})
it('writes pretty-printed JSON to disk', () => {
commands.set('hello', 'world')
const raw = fs.readFileSync(memoryFile, 'utf-8')
const parsed = JSON.parse(raw)
assert.equal(parsed.hello, 'world')
// Pretty-printed → contains newlines
assert.ok(raw.includes('\n'))
})
})
// ── commands.get ───────────────────────────────────────────────────────────
describe('commands.get', () => {
it('returns the whole memory object when no key is provided', () => {
commands.set('a', 1)
commands.set('b', 2)
const result = commands.get(undefined)
assert.equal(result.a, 1)
assert.equal(result.b, 2)
})
it('returns undefined for a key that was never set', () => {
const result = commands.get('nonexistent')
assert.equal(result, undefined)
})
it('returns the value for an existing key', () => {
commands.set('mykey', 'myvalue')
const result = commands.get('mykey')
assert.equal(result, 'myvalue')
})
})
// ── commands.set ───────────────────────────────────────────────────────────
describe('commands.set', () => {
it('returns undefined and does not write when key is missing', () => {
const result = commands.set(undefined, 'value')
assert.equal(result, undefined)
assert.ok(!fs.existsSync(memoryFile))
})
it('writes a _updated ISO timestamp alongside the value', () => {
const before = Date.now()
commands.set('ts-test', 'v')
const after = Date.now()
const raw = JSON.parse(fs.readFileSync(memoryFile, 'utf-8'))
const ts = new Date(raw._updated).getTime()
assert.ok(ts >= before && ts <= after + 100)
})
it('overwrites an existing key without losing other keys', () => {
commands.set('x', 'first')
commands.set('y', 'other')
commands.set('x', 'second')
assert.equal(commands.get('x'), 'second')
assert.equal(commands.get('y'), 'other')
})
})
// ── commands.delete ────────────────────────────────────────────────────────
describe('commands.delete', () => {
it('logs error and does not write when key is missing', () => {
const result = commands.delete(undefined)
assert.equal(result, undefined)
assert.ok(!fs.existsSync(memoryFile))
})
it('removes an existing key from memory', () => {
commands.set('to-remove', 'val')
commands.delete('to-remove')
assert.equal(commands.get('to-remove'), undefined)
})
it('is a no-op and does not throw when deleting a non-existent key', () => {
commands.set('keep', 'val')
assert.doesNotThrow(() => commands.delete('ghost'))
assert.equal(commands.get('keep'), 'val')
})
})
// ── commands.keys ──────────────────────────────────────────────────────────
describe('commands.keys', () => {
it('returns only user-defined keys, excluding _-prefixed meta keys', () => {
commands.set('alpha', 1)
commands.set('beta', 2)
const keys = commands.keys()
assert.ok(keys.includes('alpha'))
assert.ok(keys.includes('beta'))
assert.ok(!keys.includes('_updated'), '_updated must be filtered out')
})
it('returns an empty array when memory is empty', () => {
const keys = commands.keys()
assert.deepEqual(keys, [])
})
})
// ── commands.clear ─────────────────────────────────────────────────────────
describe('commands.clear', () => {
it('replaces all memory with an empty object', () => {
commands.set('a', 1)
commands.set('b', 2)
commands.clear()
assert.deepEqual(commands.get(undefined), {})
})
it('does not throw when memory is already empty', () => {
assert.doesNotThrow(() => commands.clear())
})
})
})

View file

@ -0,0 +1,199 @@
/**
* Tests for .claude/helpers/router.js
*
* Run: node --test tests/helpers/router.test.js
*
* Coverage gaps addressed:
* - routeTask: each task pattern, default fallback, case insensitivity
* - Return shape: agent, confidence, reason fields always present
* - Edge cases: empty string, whitespace-only, numeric string
* - Pattern conflicts: first-match wins (no multi-match merging)
*/
'use strict'
const { describe, it } = require('node:test')
const assert = require('node:assert/strict')
const { routeTask, AGENT_CAPABILITIES, TASK_PATTERNS } = require('../../.claude/helpers/router.js')
describe('router.js — routeTask()', () => {
// ── Return shape ────────────────────────────────────────────────────────────
describe('return shape', () => {
it('always returns an object with agent, confidence, and reason', () => {
const r = routeTask('anything')
assert.ok(typeof r.agent === 'string', 'agent must be a string')
assert.ok(typeof r.confidence === 'number', 'confidence must be a number')
assert.ok(typeof r.reason === 'string', 'reason must be a string')
})
it('confidence is between 0 and 1', () => {
const tasks = ['build a feature', 'write tests', 'random unknown task']
for (const t of tasks) {
const r = routeTask(t)
assert.ok(r.confidence >= 0 && r.confidence <= 1, `confidence out of range for: ${t}`)
}
})
})
// ── Pattern matching ────────────────────────────────────────────────────────
describe('pattern → agent routing', () => {
// NOTE: router uses first-match-wins on TASK_PATTERNS key order.
// Keywords like 'build', 'create', 'implement' match the coder pattern BEFORE
// domain-specific patterns (backend-dev, frontend-dev). Tests below reflect
// actual routing behaviour, not intuitive intent — see the "known routing quirks"
// section below for cases where first-match hides the intended agent.
const cases = [
// coder pattern: 'implement|create|build|add|write code'
['implement a login page', 'coder'],
['create a REST endpoint', 'coder'], // 'create' → coder, not backend-dev
['build authentication', 'coder'],
['add a new field to the schema', 'coder'],
['write code for the parser', 'coder'],
// tester pattern: 'test|spec|coverage|unit test|integration'
['test the signup flow', 'tester'],
['write unit tests for utils', 'tester'],
['check coverage for auth module', 'tester'],
['integration tests for payment API', 'tester'],
['write a spec for the validator', 'tester'],
// reviewer pattern: 'review|audit|check|validate|security'
['review the PR for security issues', 'reviewer'],
['audit the codebase for vulnerabilities', 'reviewer'],
['check code quality', 'reviewer'],
['validate the configuration', 'reviewer'],
// researcher pattern: 'research|find|search|documentation|explore'
['research best JWT libraries', 'researcher'],
['find documentation for Redis', 'researcher'],
['search for OAuth examples', 'researcher'],
['explore alternative approaches', 'researcher'],
// architect pattern: 'design|architect|structure|plan'
['design the microservice architecture', 'architect'],
['plan the database structure', 'architect'],
// backend-dev pattern: 'api|endpoint|server|backend|database'
// Only works when coder/tester/reviewer/researcher/architect keywords are absent
['set up the database schema', 'backend-dev'],
['the server-side middleware', 'backend-dev'], // 'server' matches backend-dev
['REST api for payment gateway', 'backend-dev'], // 'api' without documentation/search
// frontend-dev pattern: 'ui|frontend|component|react|css|style'
// Only works when patterns 1-6 don't fire first
['style the header with CSS', 'frontend-dev'],
['the UI layout mockup', 'frontend-dev'], // no review/research/create
['react hooks and state', 'frontend-dev'], // 'react' not in earlier patterns
// devops pattern: 'deploy|docker|ci|cd|pipeline|infrastructure'
['deploy to production', 'devops'],
['set up Docker Compose', 'devops'], // 'set up' doesn't match coder
['configure the CI pipeline', 'devops'],
]
for (const [task, expectedAgent] of cases) {
it(`"${task}" → ${expectedAgent}`, () => {
const r = routeTask(task)
assert.equal(r.agent, expectedAgent, `Expected ${expectedAgent}, got ${r.agent}`)
assert.equal(r.confidence, 0.8)
})
}
})
// ── Default fallback ────────────────────────────────────────────────────────
describe('default fallback', () => {
it('routes to coder with confidence 0.5 when no pattern matches', () => {
const r = routeTask('do something completely unknown xyz123')
assert.equal(r.agent, 'coder')
assert.equal(r.confidence, 0.5)
})
it('handles empty string input without throwing', () => {
assert.doesNotThrow(() => routeTask(''))
const r = routeTask('')
assert.equal(r.agent, 'coder')
assert.equal(r.confidence, 0.5)
})
it('handles whitespace-only input', () => {
const r = routeTask(' ')
assert.equal(r.agent, 'coder')
})
it('handles numeric string input', () => {
const r = routeTask('12345')
assert.equal(r.agent, 'coder')
})
})
// ── Case insensitivity ──────────────────────────────────────────────────────
describe('case insensitivity', () => {
it('matches uppercase task keywords', () => {
const r = routeTask('IMPLEMENT a feature')
assert.equal(r.agent, 'coder')
})
it('matches mixed-case task keywords', () => {
const r = routeTask('Write Tests For The Module')
assert.equal(r.agent, 'tester')
})
})
// ── AGENT_CAPABILITIES export ───────────────────────────────────────────────
describe('AGENT_CAPABILITIES', () => {
it('exports capabilities for all core agents', () => {
const expected = [
'coder',
'tester',
'reviewer',
'researcher',
'architect',
'backend-dev',
'frontend-dev',
'devops',
]
for (const agent of expected) {
assert.ok(agent in AGENT_CAPABILITIES, `Missing capabilities for: ${agent}`)
assert.ok(Array.isArray(AGENT_CAPABILITIES[agent]))
}
})
})
// ── Known routing quirks (first-match-wins causes mis-routing) ───────────────
// These are NOT bugs in the tests — they document real mis-routing that should
// be fixed by reordering TASK_PATTERNS or using a scoring approach.
describe('known routing quirks — first-match wins over domain intent', () => {
it('"create a React component" routes to coder, not frontend-dev (create matches first)', () => {
assert.equal(routeTask('create a React component').agent, 'coder')
})
it('"build the UI dashboard" routes to coder, not frontend-dev (build matches first)', () => {
assert.equal(routeTask('build the UI dashboard').agent, 'coder')
})
it('"implement server-side authentication" routes to coder, not backend-dev', () => {
assert.equal(routeTask('implement server-side authentication').agent, 'coder')
})
it('"build the REST API" routes to coder, not backend-dev (build matches first)', () => {
assert.equal(routeTask('build the REST API').agent, 'coder')
})
})
// ── Integration: first-match-wins when task matches multiple patterns ────────
describe('first-match-wins on ambiguous tasks', () => {
it('resolves to the first matching pattern when task fits multiple', () => {
// "implement api" matches 'implement' (→ coder) AND 'api' (→ backend-dev)
const r = routeTask('implement api endpoint')
assert.equal(r.agent, 'coder') // coder wins because it is listed first
assert.equal(r.confidence, 0.8)
})
})
})

Some files were not shown because too many files have changed in this diff Show more