A complete rebuild optimised for speed, scale, and rapid feature development
Based on your existing Go + Next.js experience in incremental, and research into what works best for a mobile-first PWA used outdoors on a golf course:
Why SvelteKit over Next.js for this specific app:
src/service-worker.js and it works. No extra packagesTrade-off: Smaller ecosystem than React. But GCL's needs (maps, canvas, GPS, weather) are all framework-agnostic JS APIs.
Keeps your entire stack unified with incremental. Larger ecosystem, more hiring options. But carries 3-7x more JS to the client — not ideal for on-course use with spotty connectivity.
gcl.ecaddy.co.za — not cPanel shared hostingmake release → git tag → GitHub webhook → deploy.sh on VPS
┌─────────────────────────────────────────┐
│ gcl.ecaddy.co.za │
│ (Docker) │
└────────────────┬────────────────────────┘
│
┌────────────────────┼────────────────────┐
│ │ │
┌─────▼─────┐ ┌──────▼──────┐ ┌──────▼──────┐
│ SvelteKit │ │ Go API │ │ Admin │
│ Player │ │ Server │ │ Panel │
│ (SSR/PWA) │ │ (Chi+sqlc) │ │ (SvelteKit) │
└─────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
│ ┌──────────┼──────────┐ │
│ │ │ │ │
│ ┌────▼───┐ ┌────▼───┐ ┌───▼────┐ │
│ │Postgres│ │ Redis │ │Paystack│ │
│ │ (DB) │ │(Cache) │ │ (Pay) │ │
│ └────────┘ └────────┘ └────────┘ │
│ │
└──────────┐ ┌─────────────────────────┘
│ │
┌────▼──▼────┐
│ R2 / CDN │
│ (Images) │
└────────────┘
Same pattern as incremental — no GitHub Actions
Developer: make release ↓ scripts/release.sh → pnpm changeset version (bump + changelog) → git commit + git tag @gcl/api@1.2.0 → git push origin main --tags ↓ GitHub sends webhook to VPS (gcl.ecaddy.co.za) → HMAC-SHA256 signature verified → Tag pattern matched: refs/tags/@gcl/* ↓ deploy.sh executes: 1. Acquire deploy lock (prevent concurrent deploys) 2. git pull --ff-only 3. sops decrypt secrets → .env files 4. docker compose build (tagged with version) 5. pg_dump backup → /opt/backups/gcl/ 6. goose migrations (120s timeout) 7. docker compose up -d --force-recreate 8. Health check: curl /health (15 retries) 9. On failure: rollback to previous image 10. Notify via webhook on success/failure
gcl.ecaddy.co.za to player/api/admin containers.webhook tool as incremental. Config template + HMAC secret.:rollback before deploy. Restore on health check failure.Flexible monetization that works for clubs, golfers, and growth
Today: golfer scans QR → pays R30-50 per round → gets 8-12 hours access → expires. Three limitations:
Three revenue channels, configurable per course:
Keep what works. Golfer scans QR, pays once, gets access for the round.
Improvement: If they have an account, the rental is linked to their profile (not just device).
R30-50 / round
Buy rounds upfront, use them anytime. No recurring charge anxiety — pay once, play at your pace.
Bundles:
Upfront revenue, better for casual golfers
Club pays monthly fee. All golfers get free access. White-label branding.
Includes: Course setup, analytics, pace management, slow play reporting.
R1,500-3,000/mo per course
Golfer scans QR at course
│
▼
┌─────────────────┐
│ Course Player │──── Is course free (club-pays)?
│ loads in PWA │ │
└────────┬────────┘ YES ──┼──▶ Full access immediately
│ NO ──┘
▼
┌─────────────────┐
│ Check entitlement│──── Has remaining bundle credits or active pass?
│ (device + user) │ │
└────────┬────────┘ YES ──┼──▶ Full access (deduct 1 credit)
│ NO ──┘
▼
┌─────────────────┐
│ Locked screen │ ┌──────────────────────────┐
│ │────▶│ "Play this round — R35" │ → Single charge
│ │ │ "5 rounds — R150 (save)" │ → Bundle purchase
│ │ │ "10 rounds — R250 (best)" │ → Bundle purchase
└──────────────────┘ └──────────────────────────┘
│
▼
┌─────────────────┐ Saved card? → One-tap purchase
│ First time: │ (Paystack charge_authorization)
│ enter card via │
│ Paystack modal │──── Card saved automatically for
└────────┬────────┘ future one-tap purchases
▼
┌─────────────────┐
│ Optional: create │──── "Save your preferences?"
│ account │ Email + optional password
└─────────────────┘ (or skip — device-based like today)
FIRST PURCHASE (card entry required) Backend: POST /transaction/initialize → { email, amount, metadata: { bundle: "10_rounds" } } → Returns access_code Frontend: PaystackPop.resumeTransaction(access_code) → User enters card in Paystack modal → Paystack processes payment + 3DS auth Backend: GET /transaction/verify/{reference} → Response includes authorization object: { "authorization_code": "AUTH_xxx", ← save this "reusable": true, ← must be true "last4": "4081", "card_type": "visa" } → Store auth code against user in DB → Credit 10 rounds to user's balance FUTURE PURCHASES (one-tap, no card entry) Backend: POST /transaction/charge_authorization → { "authorization_code": "AUTH_xxx", "email": "user@example.com", "amount": 25000, "metadata": { "bundle": "10_rounds" } } → Charges saved card silently → Credit 10 more rounds to balance No user interaction needed. One tap on the frontend triggers the server-side charge.
Users can manage their saved cards — list authorizations and deactivate old ones via POST /customer/authorization/deactivate. The signature field on each authorization prevents duplicate card storage.
Designed for one-handed use on the course, readable in sunlight, fast on weak signal
Mobile-first, thumb-zone optimised, sunlight-readable
Scalable course management, proper versioning, analytics
-- Core entities clubs (id, name, slug, logo_url, created_at) courses (id, club_id FK, name, slug, location, currency, price_cents, rental_duration_min, is_free, visible, created_at, updated_at) -- Versioning (proper this time) course_versions (id, course_id FK, version_number SERIAL, state ENUM(draft/published/archived), overview_image_url, mini_map_url, manifest_hash, published_at, created_by FK) -- Hole data (per version) holes (id, course_version_id FK, hole_number, title, par, stroke_index, pace_minutes, map_image_url, overview_x, overview_y) hole_tees (id, hole_id FK, tee_key, label, color, yardage_m, sort_order) hole_sim (id, hole_id FK, legs, canvas_w, canvas_h, px_per_meter, hole_rotation_deg, points JSONB, tees JSONB, geo_anchors JSONB) hole_notes (id, hole_id FK, title, body) -- Users & Auth users (id, email, password_hash, name, created_at) user_profiles (user_id FK, bag JSONB, preferred_tee, preferred_units, ground_firmness, shot_shape, wind_prefs JSONB) admin_users (id, user_id FK, role ENUM(super_admin/club_admin/viewer), club_id FK NULL) -- Monetization orders (id, reference, user_id FK NULL, device_id, course_id FK, amount_cents, currency, provider, provider_ref, status, paid_at) entitlements (id, user_id FK NULL, device_id, course_id FK, source ENUM(rental/bundle/club/manual), starts_at, ends_at, state, created_at) user_credits (id, user_id FK, credit_type ENUM(rounds/days), remaining INT, expires_at NULLABLE, purchased_at, order_id FK) payment_methods (id, user_id FK, provider, authorization_code, last4, card_type, brand, exp_month, exp_year, signature, is_default, created_at) -- Analytics & Audit round_events (id, course_id FK, user_id FK NULL, device_id, event_type, hole_number, metadata JSONB, created_at) audit_log (id, admin_user_id FK, action, entity_type, entity_id, diff JSONB, created_at) -- Indexes CREATE INDEX idx_entitlements_lookup ON entitlements (course_id, device_id, state, ends_at); CREATE INDEX idx_entitlements_user ON entitlements (user_id, course_id, state, ends_at); CREATE INDEX idx_orders_reference ON orders (reference); CREATE INDEX idx_courses_slug ON courses (slug);
// Public (no auth) GET /health GET /api/v1/courses/:slug → Course overview (public listing) GET /api/v1/courses/:slug/play → Full course JSON (entitlement-gated) POST /api/v1/courses/:slug/entitlement → Check access (device_id + user_id) // Payments POST /api/v1/orders → Create order (rental or bundle, Paystack init) POST /api/v1/orders/charge → Charge saved card (one-tap repurchase) GET /api/v1/orders/:ref/status → Poll payment status POST /api/v1/webhooks/paystack → Webhook receiver // Auth POST /api/v1/auth/register → Create account POST /api/v1/auth/login → Login → session token GET /api/v1/auth/me → Current user + profile // User (session auth) GET /api/v1/profile → Bag, prefs, history, credits balance PUT /api/v1/profile → Update preferences GET /api/v1/profile/cards → List saved payment methods DEL /api/v1/profile/cards/:id → Remove saved card GET /api/v1/profile/credits → Remaining rounds/days per course // Admin (session auth + admin role) GET /api/v1/admin/courses → All courses POST /api/v1/admin/courses → Create course PUT /api/v1/admin/courses/:id → Update course POST /api/v1/admin/courses/:id/publish → Publish draft POST /api/v1/admin/courses/:id/clone → Clone course CRUD /api/v1/admin/courses/:id/holes/* → Hole management POST /api/v1/admin/upload → Image upload → S3 GET /api/v1/admin/analytics → Dashboard stats
How the codebase is organised and how we get from v1 to v2
gcl/ ├── go/ # Go backend │ ├── cmd/gcl/main.go # Entry point │ ├── internal/ │ │ ├── config/ # Env-based configuration │ │ ├── domain/ │ │ │ ├── course/ # Course CRUD, versioning │ │ │ ├── payment/ # Paystack orders, subscriptions │ │ │ ├── entitlement/ # Access control │ │ │ ├── identity/ # Auth, sessions, profiles │ │ │ └── analytics/ # Event tracking │ │ ├── repository/ # sqlc-generated │ │ └── transport/http/ # Chi router, handlers │ ├── pkg/ │ │ ├── auth/ # JWT, sessions │ │ ├── paystack/ # Paystack client │ │ ├── storage/ # S3/R2 upload │ │ └── weather/ # Open-Meteo │ ├── migrations/ # Goose SQL │ ├── queries/ # sqlc queries │ └── Dockerfile │ ├── apps/ │ ├── player/ # SvelteKit PWA │ │ └── src/ │ │ ├── routes/ # [slug]/[hole] routing │ │ ├── lib/ │ │ │ ├── sim/ # Physics engine (ported) │ │ │ ├── gps/ # GPS stabilisation │ │ │ ├── stores/ # Svelte stores │ │ │ └── api.ts # API client │ │ ├── components/ # HoleMap, DistanceBar, etc │ │ └── service-worker.ts # Offline caching │ │ │ └── admin/ # SvelteKit admin │ └── src/ │ ├── routes/ # Dashboard, courses, holes │ └── lib/ # Admin components + API │ ├── infra/ │ ├── Caddyfile # Reverse proxy config │ ├── webhook.conf.tmpl # Webhook listener config │ └── secrets/ # sops-encrypted .enc.env files ├── scripts/ │ ├── deploy.sh # VPS deploy (triggered by webhook) │ └── release.sh # Local: changeset → tag → push ├── docker-compose.yml ├── docker-compose.prod.yml ├── Makefile └── .env.example
No backwards compatibility needed — clean cut with data migration
services: db: image: postgres:16-alpine environment: POSTGRES_DB: gcl POSTGRES_USER: gcl POSTGRES_PASSWORD: dev ports: ["5432:5432"] redis: image: redis:7-alpine ports: ["6379:6379"] api: build: ./go depends_on: [db, redis] environment: DATABASE_URL: postgres://gcl:dev@db/gcl?sslmode=disable REDIS_URL: redis://redis:6379 PAYSTACK_SECRET_KEY: ${PAYSTACK_SECRET_KEY} ports: ["8080:8080"] player: build: ./apps/player depends_on: [api] ports: ["3000:3000"] admin: build: ./apps/admin depends_on: [api] ports: ["3001:3001"]
Stack: Go (Chi + sqlc + goose) + SvelteKit (player PWA + admin) + PostgreSQL + Redis + S3/CDN
Hosting: Docker on VPS at gcl.ecaddy.co.za. Caddy reverse proxy. Tag-based webhook deploy.
Monetization: Per-round rentals + round bundles (5/10 packs) + day passes + club-pays B2B. Saved cards for one-tap repurchase.
Player UX: Light theme, bottom nav, quick-glance distances, inline settings, GPS, offline. <10KB JS.
Admin: Analytics, drag-drop images, course cloning, visual calibration, multi-tenant roles, audit log.
Migration: 7-step path. Data migration from MySQL dump. No backwards compat needed.