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>
This commit is contained in:
parent
61e73033fe
commit
dbc7e15426
2 changed files with 620 additions and 0 deletions
217
CONTEXT_HANDOVER.md
Normal file
217
CONTEXT_HANDOVER.md
Normal file
|
|
@ -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
|
||||
```
|
||||
403
implementation_plan.md
Normal file
403
implementation_plan.md
Normal file
|
|
@ -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 `<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
|
||||
```
|
||||
Loading…
Add table
Reference in a new issue