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>
18 KiB
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
@themeblock + brand colors + fonts src/app/globals.css— brand color CSS vars, Montserrat Alternates + Rubiksrc/app/(frontend)/layout.tsx— imports fonts, sets<html lang="uk">src/access/index.ts—isAdmin,isSuperAdmin,isEditor,isAdminOrPublished,isAuthenticatedsrc/collections/Users.ts— auth:true, roles (SUPER_ADMIN/ADMIN/EDITOR), saveToJWTsrc/collections/Media.ts— upload, 4 image sizes, mimeTypes incl. video + pdfsrc/payload.config.ts— buildConfig with postgresAdapter, lexicalEditorsrc/app/(payload)/admin/[[...segments]]/page.tsx— admin routingsrc/app/(payload)/admin/importMap.js— empty importMapdocker-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).npmrcwithapprove-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, draftssrc/collections/LandingPages.ts— + eventDate, stickyCta, conversionGoal, draftssrc/collections/Blog.ts— title, slug, excerpt, coverImage, category, tags, publishedAt, body (richText), SEOsrc/collections/Events.ts— + eventDate, eventEndDate, isFeatured, relatedLanding relationsrc/collections/Leads.ts— name, phone, email, label (relation), tag, status, message, guestCount, eventDate, UTM fields (utmSource/Medium/Campaign/Content/Term), googleClientId, twentyId, binotelCallId, notessrc/collections/Orders.ts— orderNumber, status, customer fields, items[], totalAmount, tickets relation, ezyPaymentUrl, UTM fields, googleClientIdsrc/collections/Tickets.ts— ticketCode (SL-YYYYMMDD-XXXXXX), order relation, categoryName, price, qrCodeUrl, isUsed, usedAt, usedByEmployeesrc/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, ordersrc/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, minHeightTextBlock— richText, align, maxWidth, backgroundColorFeaturesBlock— columns (2/3/4), items[icon/image/title/desc], backgroundColorLocationCardBlock— locations[name, slug, desc, image, badge, price, ctaLabel], showBuyButtonPricingBlock— showTicketSelector toggle → TicketsConfig or customTable, location filter, showFreeCategories, noteGalleryBlock— layout (grid/masonry/carousel), columns, images[], enableLightboxFormBlock— formType (birthday/group/callback/generic), label (relation to Labels), gtmEvent, successMessage, layout, sideImageCTABlock— title, desc, backgroundType (gradient/image/solid), buttons[], alignCountdownBlock— targetDate, expiredMessage, show flags, backgroundColor, ctaBlogPreviewBlock— source (blog/events/both), count, layout (cards/list/featured), moreLinkUrlMapBlock— 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.tssrc/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 componentsrc/components/blocks/HeroBlockComponent.tsxsrc/components/blocks/TextBlockComponent.tsxsrc/components/blocks/FeaturesBlockComponent.tsxsrc/components/blocks/LocationCardBlockComponent.tsxsrc/components/blocks/PricingBlockComponent.tsxsrc/components/blocks/GalleryBlockComponent.tsxsrc/components/blocks/FormBlockComponent.tsxsrc/components/blocks/CTABlockComponent.tsxsrc/components/blocks/CountdownBlockComponent.tsxsrc/components/blocks/BlogPreviewBlockComponent.tsxsrc/components/blocks/MapBlockComponent.tsx
Layout:
src/components/layout/Header.tsx— logo, Navigation global, headerCta button, mobile hamburgersrc/components/layout/Footer.tsx— columns from Navigation, socialLinks from SiteSettingssrc/components/layout/MobileNav.tsx— Framer Motion slide-outsrc/components/layout/BinotelWidget.tsx— script from SiteSettings.binotelWidgetHashsrc/components/layout/GTMScript.tsx— GTM snippet from SiteSettings.gtmIdsrc/components/layout/GA4Script.tsx— gtag.js from SiteSettings.ga4MeasurementIdsrc/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.tsxsrc/app/(frontend)/dyvo-lis/page.tsxsrc/app/(frontend)/labiryn/page.tsxsrc/app/(frontend)/blog/page.tsx— list with pagination + category filtersrc/app/(frontend)/blog/[slug]/page.tsx— Article structured datasrc/app/(frontend)/events/page.tsxsrc/app/(frontend)/events/[slug]/page.tsx— Event structured datasrc/app/(frontend)/landing/[slug]/page.tsx— sticky CTA barsrc/app/(frontend)/dni-narodzhennia/page.tsxsrc/app/(frontend)/grupovi-vidviduvannia/page.tsxsrc/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 + Eventssrc/app/robots.ts— from SiteSettings.robotsTxtgenerateMetadata()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 + Zodsrc/components/forms/CallbackForm.tsx— minimal (name + phone)src/app/api/leads/route.ts— POST: validate → create Lead → async Twenty sync → return successsrc/lib/twenty.ts—syncPersonToTwenty(lead)→ POST to Twenty API, save twentyIdsrc/lib/binotel.ts—verifyWebhook(),matchCallToLead(phone)src/app/api/binotel/webhook/route.ts— receive call data, match to Lead by phonesrc/lib/gtm.ts—pushEvent(name, data)→window.dataLayer.pushsrc/lib/ga4.ts—trackEvent(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 successlead_submit_group— group form successlead_submit_callback— callback form successlead_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 → emailsrc/app/api/tickets/verify/[code]/route.ts— GET: check ticketCode → { valid, categoryName } + mark isUsedsrc/lib/qr.ts— QR =https://shumiland.com.ua/api/tickets/verify/{ticketCode}src/lib/pdf.ts— @react-pdf/renderer: logo, QR, ticket details, park addresssrc/lib/email.ts— Nodemailer HTML email + PDF attachmentsrc/app/(frontend)/thank-you/page.tsx— QR codes (qrcode.react), download PDF, Шумік celebrates- GA4
purchaseevent 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/analyticswith 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 pagesrc/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/leadsand/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