shumiland-web/implementation_plan.md
Vadym Samoilenko 67140a9ec4 feat: Phase 5 — Lead API, Binotel, Twenty CRM
- src/app/api/leads/route.ts — POST /api/leads with Zod validation,
  UTM + GA Client ID from cookies, rate limiting (5 req/min/IP),
  async Twenty CRM sync (fire-and-forget)
- src/app/api/binotel/webhook/route.ts — HMAC-SHA256 verification,
  matches call to most recent lead by phone, saves binotelCallId
- src/lib/twenty.ts — GraphQL mutation to create Person in Twenty CRM
- src/lib/binotel.ts — HMAC verify, phone normalizer, payload parser
- src/lib/gtm.ts — pushGTMEvent() client helper

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 17:23:17 +01:00

403 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 `<html lang="uk">`
- [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=Шуміленд <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
```