shumiland-web/implementation_plan.md
Vadym Samoilenko dbc7e15426 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 <noreply@anthropic.com>
2026-04-02 17:56:12 +01:00

18 KiB
Raw Blame History

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:

  • Next.js 15.4.11 + Payload CMS 3.38.x init (pinned to avoid 15.5.x incompatibility)
  • docker-compose.yml — PostgreSQL (5432), Twenty CRM (3003), Umami (3001), Metabase (3002)
  • docker/postgres/init.sql — creates databases: shumiland, umami, twenty, metabase
  • .env.example — all vars: DATABASE_URL, PAYLOAD_SECRET, EZY_, SMTP_, GTM, GA4, Binotel, Umami, Twenty
  • Tailwind CSS 4 with @theme block + brand colors + fonts
  • src/app/globals.css — brand color CSS vars, Montserrat Alternates + Rubik
  • src/app/(frontend)/layout.tsx — imports fonts, sets <html lang="uk">
  • src/access/index.tsisAdmin, isSuperAdmin, isEditor, isAdminOrPublished, isAuthenticated
  • src/collections/Users.ts — auth:true, roles (SUPER_ADMIN/ADMIN/EDITOR), saveToJWT
  • src/collections/Media.ts — upload, 4 image sizes, mimeTypes incl. video + pdf
  • src/payload.config.ts — buildConfig with postgresAdapter, lexicalEditor
  • src/app/(payload)/admin/[[...segments]]/page.tsx — admin routing
  • src/app/(payload)/admin/importMap.js — empty importMap
  • docker-compose.yml + Dockerfile (multi-stage, node:20-alpine)
  • shadcn/ui components: Button (orange/green/blue/purple/yellow variants), Card, Input, Label, Textarea, Badge, Separator
  • "type": "module" in package.json (required for Payload CLI ESM)
  • .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:

  • src/collections/Labels.ts — name (unique), color (7 options), description, isActive. Group: "Маркетинг"
  • src/collections/Pages.ts — title, slug, isTemplate, blocks[] (all 11), SEO group, drafts
  • src/collections/LandingPages.ts — + eventDate, stickyCta, conversionGoal, drafts
  • src/collections/Blog.ts — title, slug, excerpt, coverImage, category, tags, publishedAt, body (richText), SEO
  • src/collections/Events.ts — + eventDate, eventEndDate, isFeatured, relatedLanding relation
  • 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
  • src/collections/Orders.ts — orderNumber, status, customer fields, items[], totalAmount, tickets relation, ezyPaymentUrl, UTM fields, googleClientId
  • src/collections/Tickets.ts — ticketCode (SL-YYYYMMDD-XXXXXX), order relation, categoryName, price, qrCodeUrl, isUsed, usedAt, usedByEmployee
  • src/globals/SiteSettings.ts — tabs: General, Соцмережі, Аналітика (GTM/GA4/Umami), Binotel, SEO, Системне (maintenance mode)
  • src/globals/TicketsConfig.ts — categories[]: categoryId, name, price, location, ageGroup, isFree, freeCondition, isActive, order
  • src/globals/Navigation.ts — headerMenu (with dropdown support), headerCta, footerColumns, footerBottomLinks
  • 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
  • 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:

// 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:

// 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:

// 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.tssyncPersonToTwenty(lead) → POST to Twenty API, save twentyId
  • src/lib/binotel.tsverifyWebhook(), matchCallToLead(phone)
  • src/app/api/binotel/webhook/route.ts — receive call data, match to Lead by phone
  • src/lib/gtm.tspushEvent(name, data)window.dataLayer.push
  • src/lib/ga4.tstrackEvent(name, params)window.gtag('event', ...)

Lead form API contract:

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.tsgetTariffs() (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

# 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=Шуміленд <tickets@shumiland.com.ua>

# 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