# 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) | ✅ Done | | 4 | Public Pages + BlockRenderer | ✅ Done | | 5 | Lead System + Binotel + Twenty CRM | ⏳ Next | | 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 ```