---
title: Shumiland — Project Overview
tags:
  - project
  - active
  - nextjs
  - payload-cms
aliases:
  - Shumiland
  - shumi
---

# Shumiland

Website for Shumiland children's entertainment park (Kyiv, Boryspil).

**URL:** https://shumi.ai-impress.com  
**Admin:** https://shumi.ai-impress.com/admin  
**Repo:** https://git.ai-impress.com/Aimpress/Shumiland  
**Server:** `aimpress` → `/opt/03-business/shumiland`

---

## Tech Stack

| Layer | Tech |
|-------|------|
| Framework | Next.js 16 (App Router, standalone output) |
| CMS | Payload CMS 3.84 (Postgres adapter) |
| DB | PostgreSQL 16 (Docker volume `shumiland-postgres-data`) |
| Email | Resend adapter (`RESEND_API_KEY`) |
| Styling | Tailwind CSS v4 |
| Package manager | pnpm 11 |
| Node.js | 22-alpine (app), alpine 3 (migrator) |
| Deploy | Docker Compose + Traefik |

---

## Deployment

```bash
# Full deploy (schema changes)
ssh -p 1220 ubuntu@57.128.160.249 "cd /opt/03-business/shumiland && git pull origin main && \
  docker compose -f docker-compose.prod.yml build app migrate && \
  docker compose -f docker-compose.prod.yml up -d --force-recreate migrate && \
  docker wait shumiland-migrate && \
  docker compose -f docker-compose.prod.yml up -d --force-recreate app && \
  docker logs shumiland-app --tail=20"

# App only (no schema changes)
ssh -p 1220 ubuntu@57.128.160.249 "cd /opt/03-business/shumiland && git pull origin main && \
  docker compose -f docker-compose.prod.yml build app && \
  docker compose -f docker-compose.prod.yml up -d --force-recreate app"

# Migrate only
ssh -p 1220 ubuntu@57.128.160.249 "cd /opt/03-business/shumiland && git pull origin main && \
  docker compose -f docker-compose.prod.yml build migrate && \
  docker compose -f docker-compose.prod.yml up --force-recreate migrate"

# Run seed (creates forms, initialises globals)
# App has no exposed port — use internal Docker IP
ssh -p 1220 ubuntu@57.128.160.249 "docker exec shumiland-app wget -qO- --post-data='' http://localhost:3000/api/admin/seed || curl -s -X POST http://\$(docker inspect shumiland-app --format '{{(index .NetworkSettings.Networks \"shumiland-internal\").IPAddress}}'):3000/api/admin/seed"

# Logs
ssh -p 1220 ubuntu@57.128.160.249 "docker logs shumiland-app --tail=30"
ssh -p 1220 ubuntu@57.128.160.249 "docker logs shumiland-migrate --tail=20"
```

---

## Migration Workflow

> **Migrator uses psql (alpine + postgresql-client), NOT payload migrate.**
> tsx@4.21.0 + Node.js v22 has unfixable ESM/CJS interop bugs — cannot run TS migrations in Docker.

Add new schema changes as SQL files in `migrations/`:
- Name format: `000N_<descriptive-name>.sql`
- All statements must be **idempotent**: `IF NOT EXISTS`, `ADD COLUMN IF NOT EXISTS`, `DO 38636 BEGIN ... EXCEPTION WHEN duplicate_object THEN null; END 38636`
- Runner uses `ON_ERROR_STOP=0` — errors are logged as warnings, not failures
- Payload TS migrations (`pnpm payload migrate:create`) are LOCAL ONLY — used to read SQL, then converted manually to idempotent SQL files

**Current SQL migrations on prod:**
| File | Contents |
|------|----------|
| `0001_new_collections.sql` | Initial schema (partial — header, home page sub-tables, locations, reviews, etc.) |
| `0002_delta.sql` | redirects plugin, globals versioning, locations extra fields |
| `0003_seed_dyvolis.sql` | DyvoLis location record |
| `0004_form_builder.sql` | forms + form_submissions tables + form_id on GlobalVisitsPage/BirthdayPage |
| `0005_site_settings_tariff_labels.sql` | site_settings_tariff_category_labels sub-table |

---

## Key Files

| File | Purpose |
|------|--------|
| `payload.config.ts` | Payload config — collections, globals, plugins |
| `src/app/(payload)/admin/importMap.js` | **Manually maintained** — add entries for new Payload plugins |
| `Dockerfile` | Multi-stage Next.js build |
| `Dockerfile.migrator` | Alpine + psql, runs `migrations/*.sql` |
| `docker-compose.prod.yml` | Prod compose: postgres → migrate → app |
| `src/components/forms/FormBlock.tsx` | Client component — renders CMS-managed forms, submits to `/api/form-submissions` |
| `src/app/api/admin/seed/route.ts` | Idempotent seed: creates forms, assigns to globals, initialises site-settings |

---

## importMap Rule

> When adding a new Payload plugin or upload field to `payload.config.ts`, manually add entries to `src/app/(payload)/admin/importMap.js`.

Hash formula: `exportName + '_' + md5(componentPath)`

Example (SEO plugin added 2026-05-13):
```
md5('@payloadcms/plugin-seo/client') = a8a977ebc872c5d5ea7ee689724c0860
```

---

## Collections & Globals

**Collections:** Users, Media, Pages, BlogPosts, Categories, Tags, Tariffs, Leads, Orders, Locations, Reviews, BirthdayPackages, **Forms** (form-builder), **FormSubmissions** (form-builder)

**Globals:** HomePage, CheckoutPage, ThankYouPage, Header, Footer, SiteSettings, DyvoLisPage, GroupVisitsPage (`form` field), BirthdayPage (`form` field)

**Plugins:** SEO, Redirects, **form-builder** (added 2026-05-18)

**SEO plugin** enabled on: `pages`, `blog-posts`, `locations`

---

## Forms (form-builder plugin)

Added `@payloadcms/plugin-form-builder@3.84.1` on 2026-05-18.

- Admin group: **Форми**
- Forms collection: CMS-managed forms with fields (text, textarea, select, email, number, date, checkbox, message)
- FormSubmissions collection: stores submitted data
- **FormBlock component** (`src/components/forms/FormBlock.tsx`) renders any form on frontend
- `GroupVisitsPage` and `BirthdayPage` globals have `form` relationship field — page uses CMS form if set, falls back to hardcoded form
- Seed creates two starter forms: **Групове відвідування** and **Замовлення дня народження**
- radio field disabled (`radio: false`) — no render case in FormBlock

**Lexical JSON note:** `field.message` (message block) and `confirmationMessage` are jsonb objects, NOT HTML strings. Use `extractLexicalText()` helper in FormBlock to render text.

---

## SiteSettings

Global slug: `site-settings`. Admin group: **Контент сайту**.

Fields: `ga4Id`, `binotelId`, `telegramChatId`, `resendFrom`, `defaultMetaTitle`, `defaultMetaDescription`, `defaultOgImage`, `tariffCategoryLabels` (array).

> ⚠️ **After deploy on 2026-05-18:** Binotel widget disappeared because `binotelId` was empty in DB. Fill it in at `/admin/globals/site-settings`.

> **Gotcha:** `site-settings` must NOT be in `livePreview.globals` in `payload.config.ts` — it's a settings global, not a page. Was causing "Nothing found" in admin.

---

## Known Issues / Gotchas

- **tsx@4.21.0 + Node.js v22 bug:** Cannot run `payload generate:importmap` or `payload migrate` in Docker. importMap.js is manually committed; migrations are raw SQL via psql.
- **Tailwind v4 cascade bug:** see `tech_gotchas.md` in memory.
- **push: true removed** — schema sync is now manual SQL migrations.
- **site-settings NOT in livePreview.globals** — causes "Nothing found" admin error if added.
- **site_settings_tariff_category_labels** — sub-table for `tariffCategoryLabels` array. Was missing from prod SQL migrations (added in 0005). If missing, every page that reads site-settings throws 500.
- **Seed runs uploadMedia every call** for certain images — `findOrUploadMedia` is idempotent (deduplicates by filename), but `uploadMedia` is not (creates duplicate). Not a critical issue but worth cleaning up.
- **home-page update in seed guarded** — skipped if `hero` field already has content, avoids invalid relationship errors when media files aren't in Docker image.
- **App container has no exposed port on host** — seed and health checks must use internal Docker IP: `docker inspect shumiland-app --format '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}'`
- **revalidate=60** on `/lokatsii/page.tsx` — was 3600, reduced to avoid stale ISR cache after deploy.

---

## Sessions Log

| Date | Work Done |
|------|----------|
| 2026-05-10 | Hero, LocationsSlider, WhyParents, Gallery, Reviews UI polish |
| 2026-05-12 | Payload upgrade 3.33→3.84, SEO plugin, CMS pages connected |
| 2026-05-13 | Fixed importMap (SEO plugin), psql migrator (tsx v22 bug), deployed successfully |
| 2026-05-15 | DyvoLis location record seeded (migration 0003) |
| 2026-05-18 | Fixed Binotel widget (empty binotelId in CMS), fixed site-settings admin (removed from livePreview), added form-builder plugin, FormBlock component, seed endpoint, migrations 0004+0005, full deploy |