From dbc7e1542625cdefa2e6bb86cca49dbc454d93c6 Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Thu, 2 Apr 2026 17:56:12 +0100 Subject: [PATCH] docs: add implementation_plan.md and CONTEXT_HANDOVER.md Full phased implementation plan with specs, file trees, and technical decisions. Context handover document for session continuity. Co-Authored-By: Claude Sonnet 4.6 --- CONTEXT_HANDOVER.md | 217 ++++++++++++++++++++++ implementation_plan.md | 403 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 620 insertions(+) create mode 100644 CONTEXT_HANDOVER.md create mode 100644 implementation_plan.md diff --git a/CONTEXT_HANDOVER.md b/CONTEXT_HANDOVER.md new file mode 100644 index 0000000..38e78dd --- /dev/null +++ b/CONTEXT_HANDOVER.md @@ -0,0 +1,217 @@ +# Context Handover — Shumiland Web + +**Last updated:** 2026-04-02 +**Git branch:** main +**Last commit:** `feat: Phase 2 complete — content model, blocks, collections, globals` + +--- + +## 1. Current Project Status + +**Phases 1 and 2 are fully complete and committed.** + +The project has a working Next.js 15.4.11 + Payload CMS 3.x foundation with the entire content model defined. The admin panel (`/admin`) is functional. All 10 collections, 3 globals, and 11 page builder blocks are registered and TypeScript types are generated. + +The site currently has no public-facing pages beyond a placeholder homepage. No middleware, no public routes, no React components for blocks — that is Phase 3+ work. + +**To start the dev server** (requires PostgreSQL running via Docker): +```bash +docker-compose up -d postgres +pnpm dev +# Admin: http://localhost:3000/admin +``` + +**To regenerate types** after schema changes: +```bash +pnpm generate:types +``` + +--- + +## 2. What Was Just Built and Verified + +### Phase 1 (Foundation) — verified working: +- Next.js 15.4.11 + Payload CMS 3.x embedded (single project, single `pnpm dev`) +- PostgreSQL via Docker, Drizzle ORM adapter +- Tailwind CSS 4 with `@theme` + all brand colors + Montserrat Alternates + Rubik fonts +- `src/access/index.ts` — `isAdmin`, `isEditor`, `isAdminOrPublished`, etc. +- `Users` collection — roles (SUPER_ADMIN/ADMIN/EDITOR), saveToJWT +- `Media` collection — 4 image sizes + video/pdf support +- shadcn/ui components: Button (5 color variants), Card, Input, Label, Textarea, Badge, Separator +- Admin panel accessible at `/admin`, Payload route group `(payload)` isolated from `(frontend)` + +### Phase 2 (Content Model) — verified with `pnpm generate:types`: +- **11 blocks** in `src/blocks/` — all registered in Pages + LandingPages collections +- **10 collections** — all registered in `payload.config.ts` +- **3 globals** — SiteSettings, TicketsConfig, Navigation +- **`src/payload-types.ts`** — 1874 lines generated, all interfaces present: + `User`, `Media`, `Label`, `Page`, `LandingPage`, `Blog`, `Event`, `Lead`, `Order`, `Ticket`, all block interfaces, all global interfaces + +### Critical fix discovered during Phase 2: +- `"type": "module"` was missing from `package.json` — Payload CLI (tsx) could not resolve module imports without it. Added and verified working. +- `Labels.ts` uses `export default` (not named export) — `payload.config.ts` must use `import Labels from '...'` + +--- + +## 3. Immediate Next Step: Phase 3 — Middleware + +Create two files: + +### `src/middleware.ts` +```typescript +import { NextRequest, NextResponse } from 'next/server' + +export function middleware(request: NextRequest) { + const response = NextResponse.next() + const { searchParams } = request.nextUrl + + const utmParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'] + + for (const param of utmParams) { + const value = searchParams.get(param) + // First-touch: only set if not already in cookies + if (value && !request.cookies.has(param)) { + response.cookies.set(param, value, { + httpOnly: true, + maxAge: 60 * 60 * 24 * 30, // 30 days + sameSite: 'lax', + path: '/', + }) + } + } + + return response +} + +export const config = { + matcher: [ + '/((?!api|admin|_next/static|_next/image|favicon.ico|fonts|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)).*)', + ], +} +``` + +### `src/lib/utm.ts` +```typescript +import type { ReadonlyRequestCookies } from 'next/dist/server/web/spec-extension/adapters/request-cookies' + +export interface UtmData { + utmSource: string | null + utmMedium: string | null + utmCampaign: string | null + utmContent: string | null + utmTerm: string | null +} + +export function getUtmFromCookies(cookies: ReadonlyRequestCookies): UtmData { + return { + utmSource: cookies.get('utm_source')?.value ?? null, + utmMedium: cookies.get('utm_medium')?.value ?? null, + utmCampaign: cookies.get('utm_campaign')?.value ?? null, + utmContent: cookies.get('utm_content')?.value ?? null, + utmTerm: cookies.get('utm_term')?.value ?? null, + } +} + +// Parses _ga cookie: "GA1.2.1234567890.1234567890" → "1234567890.1234567890" +export function getGoogleClientId(cookies: ReadonlyRequestCookies): string | null { + const ga = cookies.get('_ga')?.value + if (!ga) return null + const parts = ga.split('.') + if (parts.length >= 4) return `${parts[2]}.${parts[3]}` + return null +} +``` + +After Phase 3, proceed to Phase 4 (BlockRenderer + layout components + public routes). + +--- + +## 4. Important Technical Decisions + +### Stack choices: +| Decision | Choice | Why | +|----------|--------|-----| +| CMS | Payload CMS 3.x | Embedded in Next.js, page builder, non-tech admin, self-hosted | +| Next.js | 15.4.11 pinned | 15.5.x breaks Payload peer deps | +| ORM | Drizzle (via Payload) | Payload's native adapter — no Prisma | +| Styling | Tailwind CSS 4 + `@theme` | CSS-native, no config file needed for tokens | +| Font | **Montserrat Alternates** + Rubik | Discovered from live site HTML — NOT regular Montserrat | +| Payment | ezy.com.ua (not "Kasa") | Actual provider with Monobank redirect | +| i18n | None | Ukrainian only — no language switching, ever | + +### Module system: +- **`"type": "module"` in `package.json` is required** — without it, `pnpm generate:types` fails because Payload CLI (tsx) can't resolve `.ts` imports +- All imports in `payload.config.ts` use relative paths WITHOUT extension (e.g., `./collections/Blog`) — tsx resolves `.ts` automatically in ESM mode +- `@/` path aliases DO NOT work in Payload CLI context — use relative paths in `payload.config.ts` + +### Collections export style: +- `Users.ts` → `export default Users` (use `import Users from '...'`) +- `Media.ts` → `export default Media` (use `import Media from '...'`) +- `Labels.ts` → `export default Labels` (use `import Labels from '...'`) ← **gotcha** +- All other collections/globals/blocks → named exports (use `import { X } from '...'`) + +### Lead tracking architecture: +- `Labels` collection — admin creates labels (e.g. "Instagram Літо 2026", "Google Ads March") +- `FormBlock` has a `label` relationship field — admin assigns label when adding form to page +- On form submit → API saves `labelId` + UTM cookies + Google Client ID (from `_ga` cookie) to `Lead` +- This enables per-campaign attribution without code changes + +### Binotel: +- Widget hash: `fgfz5owkoc9rxip2brp2` (stored in SiteSettings, loaded via script) +- API webhooks receive call data → match by phone to existing Lead → update `binotelCallId` + +### ezy.com.ua tariff IDs (from existing site): +- Dinopark: 3120 (adult), 3180 (child), 3240 (senior) +- DyvoLis: 3300 (adult), 3360 (child) +- Labiryn: 3420 + +### Analytics dual-tracking: +- GTM (GTM-KJTSVLBC) — primary, for tag management + remarketing +- GA4 — direct via gtag.js (in addition to GTM) for reliability +- Umami — privacy-friendly backup analytics (self-hosted in Docker) +- IDs stored in SiteSettings global → admin can update without code deploy + +--- + +## File Tree (Phases 1-2 complete) + +``` +src/ +├── access/index.ts ✅ +├── app/ +│ ├── (frontend)/layout.tsx ✅ fonts + lang="uk" +│ ├── (frontend)/page.tsx ✅ placeholder +│ ├── (payload)/admin/... ✅ admin routing +│ ├── globals.css ✅ brand colors + fonts +│ └── layout.tsx ✅ +├── blocks/ ✅ 11/11 complete +├── collections/ ✅ 10/10 complete +├── components/ui/ ✅ 7 shadcn components +├── globals/ ✅ 3/3 complete +├── lib/utils.ts ✅ +├── payload.config.ts ✅ all registered +└── payload-types.ts ✅ 1874 lines +``` + +**Still to create (Phases 3–8):** +``` +src/ +├── middleware.ts ← Phase 3 NEXT +├── lib/utm.ts ← Phase 3 NEXT +├── components/ +│ ├── blocks/BlockRenderer.tsx + 11× ← Phase 4 +│ └── layout/Header, Footer, scripts ← Phase 4 +├── app/(frontend)/ ← Phase 4 +│ ├── [all public routes] +│ └── not-found.tsx +├── app/api/ +│ ├── leads/route.ts ← Phase 5 +│ ├── binotel/webhook/route.ts ← Phase 5 +│ └── tickets/... ← Phase 6 +├── lib/ +│ ├── ezy.ts, qr.ts, pdf.ts ← Phase 6 +│ ├── email.ts ← Phase 6 +│ ├── binotel.ts, twenty.ts ← Phase 5 +│ └── gtm.ts, ga4.ts ← Phase 7 +└── seed.ts ← Phase 8 +``` diff --git a/implementation_plan.md b/implementation_plan.md new file mode 100644 index 0000000..e69156b --- /dev/null +++ b/implementation_plan.md @@ -0,0 +1,403 @@ +# Shumiland Web — Implementation Plan + +## Project Overview + +Corporate website for Shumiland family theme park (shumiland.com.ua). +Stack: Next.js 15.4.11 + Payload CMS 3.x + PostgreSQL + Tailwind CSS 4 + shadcn/ui. +Language: Ukrainian only. No i18n. + +--- + +## Phase Status + +| Phase | Name | Status | +|-------|------|--------| +| 1 | Foundation (Next.js + Payload + DB) | ✅ Done | +| 2 | Content Model (Collections + Globals + Blocks) | ✅ Done | +| 3 | Middleware (UTM + Google Client ID) | ⏳ Next | +| 4 | Public Pages + BlockRenderer | ⬜ Pending | +| 5 | Lead System + Binotel + Twenty CRM | ⬜ Pending | +| 6 | Ticket Payment (ezy.com.ua → Monobank) | ⬜ Pending | +| 7 | Analytics (GTM + GA4 + Umami) + Employee Portal | ⬜ Pending | +| 8 | Polish + Seed + Docker finalization | ⬜ Pending | + +--- + +## Phase 1 — Foundation ✅ + +**Goal:** Working Next.js 15 + Payload CMS 3.x + PostgreSQL, auth, admin panel. + +### Completed: +- [x] Next.js 15.4.11 + Payload CMS 3.38.x init (pinned to avoid 15.5.x incompatibility) +- [x] `docker-compose.yml` — PostgreSQL (5432), Twenty CRM (3003), Umami (3001), Metabase (3002) +- [x] `docker/postgres/init.sql` — creates databases: shumiland, umami, twenty, metabase +- [x] `.env.example` — all vars: DATABASE_URL, PAYLOAD_SECRET, EZY_*, SMTP_*, GTM, GA4, Binotel, Umami, Twenty +- [x] Tailwind CSS 4 with `@theme` block + brand colors + fonts +- [x] `src/app/globals.css` — brand color CSS vars, Montserrat Alternates + Rubik +- [x] `src/app/(frontend)/layout.tsx` — imports fonts, sets `` +- [x] `src/access/index.ts` — `isAdmin`, `isSuperAdmin`, `isEditor`, `isAdminOrPublished`, `isAuthenticated` +- [x] `src/collections/Users.ts` — auth:true, roles (SUPER_ADMIN/ADMIN/EDITOR), saveToJWT +- [x] `src/collections/Media.ts` — upload, 4 image sizes, mimeTypes incl. video + pdf +- [x] `src/payload.config.ts` — buildConfig with postgresAdapter, lexicalEditor +- [x] `src/app/(payload)/admin/[[...segments]]/page.tsx` — admin routing +- [x] `src/app/(payload)/admin/importMap.js` — empty importMap +- [x] `docker-compose.yml` + `Dockerfile` (multi-stage, node:20-alpine) +- [x] shadcn/ui components: Button (orange/green/blue/purple/yellow variants), Card, Input, Label, Textarea, Badge, Separator +- [x] `"type": "module"` in package.json (required for Payload CLI ESM) +- [x] `.npmrc` with `approve-builds=true` + +**Brand colors:** +``` +brand-green: #223D17 +brand-green-mid: #446231 +brand-orange: #F28E44 +brand-yellow: #FCDD67 +brand-blue: #3E4095 +brand-purple: #7152A1 +brand-black: #231F20 +``` + +--- + +## Phase 2 — Content Model ✅ + +**Goal:** All content types defined. Admin can create and edit everything. + +### Completed: +- [x] `src/collections/Labels.ts` — name (unique), color (7 options), description, isActive. Group: "Маркетинг" +- [x] `src/collections/Pages.ts` — title, slug, isTemplate, blocks[] (all 11), SEO group, drafts +- [x] `src/collections/LandingPages.ts` — + eventDate, stickyCta, conversionGoal, drafts +- [x] `src/collections/Blog.ts` — title, slug, excerpt, coverImage, category, tags, publishedAt, body (richText), SEO +- [x] `src/collections/Events.ts` — + eventDate, eventEndDate, isFeatured, relatedLanding relation +- [x] `src/collections/Leads.ts` — name, phone, email, **label** (relation), tag, status, message, guestCount, eventDate, UTM fields (utmSource/Medium/Campaign/Content/Term), **googleClientId**, twentyId, binotelCallId, notes +- [x] `src/collections/Orders.ts` — orderNumber, status, customer fields, items[], totalAmount, tickets relation, ezyPaymentUrl, UTM fields, googleClientId +- [x] `src/collections/Tickets.ts` — ticketCode (SL-YYYYMMDD-XXXXXX), order relation, categoryName, price, qrCodeUrl, isUsed, usedAt, usedByEmployee +- [x] `src/globals/SiteSettings.ts` — tabs: General, Соцмережі, Аналітика (GTM/GA4/Umami), Binotel, SEO, Системне (maintenance mode) +- [x] `src/globals/TicketsConfig.ts` — categories[]: categoryId, name, price, location, ageGroup, isFree, freeCondition, isActive, order +- [x] `src/globals/Navigation.ts` — headerMenu (with dropdown support), headerCta, footerColumns, footerBottomLinks +- [x] 11 Page Builder blocks in `src/blocks/`: + - `HeroBlock` — backgroundType (video/image/gradient), cta + secondaryCta, gtmEvent, minHeight + - `TextBlock` — richText, align, maxWidth, backgroundColor + - `FeaturesBlock` — columns (2/3/4), items[icon/image/title/desc], backgroundColor + - `LocationCardBlock` — locations[name, slug, desc, image, badge, price, ctaLabel], showBuyButton + - `PricingBlock` — showTicketSelector toggle → TicketsConfig or customTable, location filter, showFreeCategories, note + - `GalleryBlock` — layout (grid/masonry/carousel), columns, images[], enableLightbox + - `FormBlock` — formType (birthday/group/callback/generic), **label** (relation to Labels), gtmEvent, successMessage, layout, sideImage + - `CTABlock` — title, desc, backgroundType (gradient/image/solid), buttons[], align + - `CountdownBlock` — targetDate, expiredMessage, show flags, backgroundColor, cta + - `BlogPreviewBlock` — source (blog/events/both), count, layout (cards/list/featured), moreLinkUrl + - `MapBlock` — embedUrl, mapHeight, transportInfo[], workingHours[], directionsUrl +- [x] `src/payload-types.ts` — generated (1874 lines), all interfaces verified + +--- + +## Phase 3 — Middleware + UTM + Google Client ID ⏳ NEXT + +**Goal:** Middleware captures UTM params and Google Client ID from every request, stores in httpOnly cookies. + +### Files to create: +- [ ] `src/middleware.ts` +- [ ] `src/lib/utm.ts` + +### `src/middleware.ts` spec: +```typescript +// Reads from URL: utm_source, utm_medium, utm_campaign, utm_content, utm_term +// Sets httpOnly cookies (30-day expiry) — only if present in query +// Does NOT overwrite existing cookies (first-touch attribution) +// Passes request through unchanged +// Matcher: excludes /api/*, /admin/*, /_next/*, /fonts/*, *.{ico,svg,png,jpg,webp} +``` + +### `src/lib/utm.ts` spec: +```typescript +// getUtmFromCookies(cookies: ReadonlyRequestCookies): UtmData +// Returns: { utmSource, utmMedium, utmCampaign, utmContent, utmTerm } + +// getGoogleClientId(cookies: ReadonlyRequestCookies): string | null +// Reads `_ga` cookie, parses format: GA1.X.XXXXXXXXXX.XXXXXXXXXX → "XXXXXXXXXX.XXXXXXXXXX" +``` + +### Maintenance mode: +```typescript +// In middleware: read SiteSettings global from Payload +// If maintenanceMode === true AND request is not from authenticated admin +// → redirect to /maintenance +// Create src/app/(frontend)/maintenance/page.tsx +``` + +--- + +## Phase 4 — Public Pages + BlockRenderer ⬜ + +**Goal:** All routes render CMS content via blocks. + +### Files to create: + +#### Core infrastructure: +- [ ] `src/components/blocks/BlockRenderer.tsx` — maps blockType → React component +- [ ] `src/components/blocks/HeroBlockComponent.tsx` +- [ ] `src/components/blocks/TextBlockComponent.tsx` +- [ ] `src/components/blocks/FeaturesBlockComponent.tsx` +- [ ] `src/components/blocks/LocationCardBlockComponent.tsx` +- [ ] `src/components/blocks/PricingBlockComponent.tsx` +- [ ] `src/components/blocks/GalleryBlockComponent.tsx` +- [ ] `src/components/blocks/FormBlockComponent.tsx` +- [ ] `src/components/blocks/CTABlockComponent.tsx` +- [ ] `src/components/blocks/CountdownBlockComponent.tsx` +- [ ] `src/components/blocks/BlogPreviewBlockComponent.tsx` +- [ ] `src/components/blocks/MapBlockComponent.tsx` + +#### Layout: +- [ ] `src/components/layout/Header.tsx` — logo, Navigation global, headerCta button, mobile hamburger +- [ ] `src/components/layout/Footer.tsx` — columns from Navigation, socialLinks from SiteSettings +- [ ] `src/components/layout/MobileNav.tsx` — Framer Motion slide-out +- [ ] `src/components/layout/BinotelWidget.tsx` — script from SiteSettings.binotelWidgetHash +- [ ] `src/components/layout/GTMScript.tsx` — GTM snippet from SiteSettings.gtmId +- [ ] `src/components/layout/GA4Script.tsx` — gtag.js from SiteSettings.ga4MeasurementId +- [ ] `src/components/layout/UmamiScript.tsx` — from SiteSettings.umamiScriptUrl +- [ ] Update `src/app/(frontend)/layout.tsx` — import and render all above + +#### Pages: +- [ ] `src/app/(frontend)/page.tsx` — home (slug: 'home') +- [ ] `src/app/(frontend)/dinopark/page.tsx` +- [ ] `src/app/(frontend)/dyvo-lis/page.tsx` +- [ ] `src/app/(frontend)/labiryn/page.tsx` +- [ ] `src/app/(frontend)/blog/page.tsx` — list with pagination + category filter +- [ ] `src/app/(frontend)/blog/[slug]/page.tsx` — Article structured data +- [ ] `src/app/(frontend)/events/page.tsx` +- [ ] `src/app/(frontend)/events/[slug]/page.tsx` — Event structured data +- [ ] `src/app/(frontend)/landing/[slug]/page.tsx` — sticky CTA bar +- [ ] `src/app/(frontend)/dni-narodzhennia/page.tsx` +- [ ] `src/app/(frontend)/grupovi-vidviduvannia/page.tsx` +- [ ] `src/app/(frontend)/[slug]/page.tsx` — catch-all CMS (MUST BE LAST) +- [ ] `src/app/(frontend)/not-found.tsx` — 404 with Шумік character + +#### SEO: +- [ ] `src/app/sitemap.ts` — auto-generate from Pages + Blog + Events +- [ ] `src/app/robots.ts` — from SiteSettings.robotsTxt +- [ ] `generateMetadata()` in every page — from CMS SEO group + SiteSettings defaults +- [ ] Structured data: Organization, LocalBusiness, Article, Event schemas + +--- + +## Phase 5 — Lead System + Binotel + Twenty CRM ⬜ + +**Goal:** Forms capture leads with labels, UTM, Google Client ID. Sync to Twenty CRM. Binotel tracking. + +### Files to create: +- [ ] `src/components/forms/LeadForm.tsx` — 4 variants (birthday/group/callback/generic), RHF + Zod +- [ ] `src/components/forms/CallbackForm.tsx` — minimal (name + phone) +- [ ] `src/app/api/leads/route.ts` — POST: validate → create Lead → async Twenty sync → return success +- [ ] `src/lib/twenty.ts` — `syncPersonToTwenty(lead)` → POST to Twenty API, save twentyId +- [ ] `src/lib/binotel.ts` — `verifyWebhook()`, `matchCallToLead(phone)` +- [ ] `src/app/api/binotel/webhook/route.ts` — receive call data, match to Lead by phone +- [ ] `src/lib/gtm.ts` — `pushEvent(name, data)` → `window.dataLayer.push` +- [ ] `src/lib/ga4.ts` — `trackEvent(name, params)` → `window.gtag('event', ...)` + +### Lead form API contract: +```typescript +POST /api/leads +Body: { + name: string + phone?: string + email?: string + message?: string + guestCount?: number + eventDate?: string + organization?: string + tag: 'birthday' | 'group' | 'callback' | 'generic' + labelId?: string // from FormBlock CMS config +} +// Middleware adds UTM + googleClientId from cookies automatically +``` + +### GTM events to fire: +- `lead_submit_birthday` — birthday form success +- `lead_submit_group` — group form success +- `lead_submit_callback` — callback form success +- `lead_submit_generic` — generic form success + +--- + +## Phase 6 — Ticket Payment ⬜ + +**Goal:** Ticket selection → ezy.com.ua → Monobank payment → QR tickets via email. + +### ezy.com.ua API: +``` +GET https://www.ezy.com.ua/ipay/default/get-partner-tariff?activity={EZY_ACTIVITY_KEY} +POST https://www.ezy.com.ua/ipay/pay/partner-pay + Content-Type: multipart/form-data + Fields: partner_key, email, order (JSON) +Response: { url: "https://pay.monobank.ua/..." } +``` + +### Files to create: +- [ ] `src/lib/ezy.ts` — `getTariffs()` (5min cache), `createPayment({ email, order })` +- [ ] `src/app/(frontend)/payments/page.tsx` — 3-step checkout: + - Step 1: Ticket selector (categories from TicketsConfig, +/- counters, total) + - Step 2: Buyer data (name required, email required, phone) + - Step 3: Order review + pay button +- [ ] `src/app/api/tickets/create/route.ts` — POST: Order(PENDING) → ezy.createPayment() → { paymentUrl } +- [ ] `src/app/api/tickets/webhook/route.ts` — verify → Order.status=PAID → create Tickets → QR → email +- [ ] `src/app/api/tickets/verify/[code]/route.ts` — GET: check ticketCode → { valid, categoryName } + mark isUsed +- [ ] `src/lib/qr.ts` — QR = `https://shumiland.com.ua/api/tickets/verify/{ticketCode}` +- [ ] `src/lib/pdf.ts` — @react-pdf/renderer: logo, QR, ticket details, park address +- [ ] `src/lib/email.ts` — Nodemailer HTML email + PDF attachment +- [ ] `src/app/(frontend)/thank-you/page.tsx` — QR codes (qrcode.react), download PDF, Шумік celebrates +- [ ] GA4 `purchase` event on thank-you page + +### Ticket code format: `SL-YYYYMMDD-XXXXXX` (e.g. `SL-20260402-A3K7F2`) + +### Known tariff IDs from existing site: +- 3120, 3180, 3240 — Dinopark (adult/child/senior) +- 3300, 3360 — DyvoLis (adult/child) +- 3420 — Labiryn + +--- + +## Phase 7 — Analytics + Employee Portal ⬜ + +**Goal:** Analytics working, admin dashboard, employee portal. + +### Files to create: +- [ ] Wire GTM events to all components (hero_cta_click, ticket_cta_click, blog_view, etc.) +- [ ] `src/app/(frontend)/portal/page.tsx` — auth check, role-based card grid +- [ ] Optional: custom Payload admin view `/admin/analytics` with Recharts graphs + +### GTM event map: +| Event | Trigger | +|-------|---------| +| `hero_cta_click` | HeroBlock CTA button | +| `ticket_cta_click` | PricingBlock / any ticket buy button | +| `purchase` | thank-you page load (totalAmount, items[]) | +| `lead_submit_{tag}` | LeadForm success | +| `blog_view` | Blog detail page load | +| `event_landing_view` | Landing page load | +| `landing_sticky_cta_click` | Sticky CTA on LandingPage | + +### Employee Portal cards by role: +- **All roles**: Розклад, Контакти, Внутрішні ресурси +- **EDITOR+**: CMS Admin (/admin) +- **ADMIN+**: Metabase (bi.shumiland.com.ua), Umami (analytics.shumiland.com.ua), Twenty CRM (crm.shumiland.com.ua) + +--- + +## Phase 8 — Polish + Seed + Docker ⬜ + +**Goal:** Production-ready, all edge cases covered. + +### Files to create/update: +- [ ] `src/app/(frontend)/not-found.tsx` — 404 with Шумік (green fluffy character) +- [ ] `src/app/(frontend)/maintenance/page.tsx` — maintenance mode page +- [ ] `src/app/(frontend)/loading.tsx` — skeleton loaders +- [ ] Framer Motion animations: fade-in hero, stagger features, slide-in nav, hover gallery +- [ ] `src/seed.ts` — seed script: + - Default admin user (from .env) + - Homepage with 5 blocks (Hero, LocationCard, Features, BlogPreview, CTA) + - SiteSettings: Шуміленд, +38 (067) 123-45-67, address + - Navigation: header (Локації, Купити квиток, Блог, Контакти) + footer + - TicketsConfig: 7 categories (Dinopark ×3, DyvoLis ×2, Labiryn ×1, Combo ×1) + - Labels: birthday, group, callback, instagram, google-ads + - Sample blog post + event +- [ ] Performance: Payload select/depth optimization, Next.js Image, ISR revalidation + - homepage: 60s, blog: 300s, static pages: 3600s +- [ ] Security: rate limiting `/api/leads` and `/api/tickets/create`, webhook HMAC verification, CSP headers +- [ ] `Dockerfile` — multi-stage build, `output: standalone` +- [ ] Lighthouse audit: target 90+ on Performance, Accessibility, SEO + +--- + +## Key Technical Decisions + +| Decision | Choice | Reason | +|----------|--------|--------| +| CMS | Payload CMS 3.x (not TinaCMS) | Self-hosted, embedded in Next.js, non-tech admin UI, page builder | +| Next.js version | 15.4.11 pinned | 15.5.x incompatible with Payload peer deps | +| Database | PostgreSQL + Drizzle ORM | Payload's native ORM, no Prisma needed | +| Package type | `"type": "module"` | Required for Payload CLI ESM module resolution (tsx doesn't resolve @/ aliases) | +| Font | Montserrat Alternates + Rubik | Current site uses Alternates, not regular Montserrat | +| Payment | ezy.com.ua → Monobank | Actual provider extracted from existing site HTML | +| Form labels | Admin-configurable (Labels collection) | Track lead sources per campaign without code deploys | +| UTM tracking | First-touch, httpOnly cookies, 30 days | Standard attribution model | +| Google Client ID | From `_ga` cookie → saved in Lead/Order | Links CRM leads to GA4 sessions | +| Call tracking | Binotel widget + API webhooks | Widget hash: fgfz5owkoc9rxip2brp2 | +| Analytics | GTM (GTM-KJTSVLBC) + GA4 + Umami dual | GTM for tag management, Umami for privacy-friendly backup | +| Auth | Payload built-in (3 roles) | No external auth needed | +| i18n | None | Ukrainian only by decision | + +--- + +## File Tree (current state) + +``` +src/ +├── access/index.ts ✅ +├── app/ +│ ├── (frontend)/ +│ │ ├── layout.tsx ✅ (fonts, lang="uk") +│ │ └── page.tsx ✅ (placeholder) +│ ├── (payload)/ +│ │ └── admin/ +│ │ ├── [[...segments]]/page.tsx ✅ +│ │ └── importMap.js ✅ +│ ├── globals.css ✅ (brand colors + fonts) +│ └── layout.tsx ✅ +├── blocks/ ✅ ALL 11 DONE +│ ├── BlogPreviewBlock.ts, CTABlock.ts, CountdownBlock.ts +│ ├── FeaturesBlock.ts, FormBlock.ts, GalleryBlock.ts +│ ├── HeroBlock.ts, LocationCardBlock.ts, MapBlock.ts +│ ├── PricingBlock.ts, TextBlock.ts +├── collections/ ✅ ALL DONE +│ ├── Blog.ts, Events.ts, Labels.ts, LandingPages.ts +│ ├── Leads.ts, Media.ts, Orders.ts, Pages.ts +│ ├── Tickets.ts, Users.ts +├── components/ui/ ✅ shadcn components +│ ├── badge.tsx, button.tsx, card.tsx +│ ├── input.tsx, label.tsx, separator.tsx, textarea.tsx +├── globals/ ✅ ALL DONE +│ ├── Navigation.ts, SiteSettings.ts, TicketsConfig.ts +├── lib/utils.ts ✅ +├── payload.config.ts ✅ all registered +└── payload-types.ts ✅ generated (1874 lines) +``` + +--- + +## Environment Variables Required + +```bash +# Database +DATABASE_URL=postgresql://shumiland:shumiland@localhost:5432/shumiland + +# Payload CMS +PAYLOAD_SECRET=your-secret-here +NEXT_PUBLIC_SITE_URL=http://localhost:3000 + +# ezy.com.ua (ticket payments) +EZY_ACTIVITY_KEY=your-activity-key +EZY_PARTNER_KEY=your-partner-key + +# SMTP (email tickets) +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER=tickets@shumiland.com.ua +SMTP_PASS=your-password +SMTP_FROM=Шуміленд + +# Analytics +NEXT_PUBLIC_GTM_ID=GTM-KJTSVLBC +NEXT_PUBLIC_GA4_ID=G-XXXXXXXXXX + +# Binotel +NEXT_PUBLIC_BINOTEL_HASH=fgfz5owkoc9rxip2brp2 +BINOTEL_API_KEY=your-key + +# Umami +NEXT_PUBLIC_UMAMI_WEBSITE_ID=your-uuid +NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.shumiland.com.ua/script.js + +# Twenty CRM +TWENTY_API_URL=http://localhost:3003 +TWENTY_API_KEY=your-key +```