specs / 036-Changes-Promo-Carousel-Upcoming-Match

036 - Promo carousel + Upcoming Match restructure

  • Tanggal: 2026-08-15
  • Chat/topik: Tukar banner promo Home jadi carousel gambar, pindahkan Upcoming Match ke atas Quick Actions, gating tombol Play, cron no-show/no-checkout

Ringkasan perubahan

  • Header Home: kartu "Upcoming Match" di header diganti carousel gambar promo (looping, auto-advance ~4.5s, swipeable, dot indicator). Hanya promo dengan image_url yang tampil di carousel, diurutkan display_order ASC, created_at ASC. Tanpa promo bergambar aktif → slot disembunyikan, header lebih ringkas.
  • Kartu "Upcoming Match" (real booking terdekat) dipindah jadi section tersendiri di atas "Quick Actions", tombol "Play Live" diganti label "Play" dan hanya tampil ketika waktu sekarang berada dalam window [start_time, end_time) booking tersebut (dicek ulang tiap ~30 detik). Tanpa jam main aktif → kartu tetap tampil info, tombol disembunyikan.
  • Section "Promotions" (banner teks/emoji, statis) di bagian bawah Home dihapus — sudah tercover carousel gambar di header, menghindari duplikasi.
  • Backend: Promo entity tambah image_url + display_order; GET /content/home active_promos[] diurutkan sesuai display_order.
  • Backend: cron CleanupPastBookings (ticker 5 menit, sudah ada) diperluas — sebelumnya hanya auto-complete booking yang lewat waktu dan tidak pernah check-in (no-show); sekarang juga cover booking yang sudah check-in tapi lupa check-out. Status tetap Completed (tidak ada enum baru).
  • Admin: form Promo (/customers/promos) tambah field Image URL + Display Order untuk mengatur carousel.

Impact bisnis

Home terasa lebih hidup dengan visual promo asli (bukan teks statis), dan member langsung tahu kapan CTA "Play" relevan (bukan selalu muncul walau belum jam main). Cron yang diperluas menutup gap operasional: slot yang lupa di-checkout di kasir tidak lagi menggantung status "Confirmed" selamanya.

Epic / User stories

  • Epic: Home delight + booking lifecycle hygiene
  • US: Sebagai member, saya ingin melihat promo dalam bentuk gambar menarik di Home, agar saya lebih tergerak menjajal promo tersebut.
  • US: Sebagai member, saya ingin tombol "Play" hanya muncul saat memang sudah waktunya main, agar saya tidak salah paham bisa langsung mulai match di luar jam booking.
  • US: Sebagai staff/ops, saya ingin booking yang lupa check-out otomatis ditutup setelah jam selesai, agar data status booking tetap bersih tanpa intervensi manual.

User terdampak

Member (tab Home, Android & iOS). Staff/ops secara tidak langsung (kebersihan status booking).

Aplikasi terdampak

  • mpc-be — PERLU (v1.9): Promo.ImageURL/DisplayOrder, GetActivePromos ordering, CleanupPastBookings extended, api_contract.md update.
  • mpc-admin — PERLU (v0.10): Prisma Promo.imageUrl/displayOrder, form fields.
  • mpc-android — PERLU (v1.19, bullet tambahan chat ini): PromoCarousel, relokasi Upcoming Match, gating Play, hapus section Promotions lama.
  • mpc-ios — PERLU (v1.8, bullet tambahan chat ini, parity 1:1).
  • mpc-web — TIDAK — belum ada Home screen live.
  • mpc-be-pos — TIDAK — semantik status booking tidak berubah (tetap Completed, tidak ada enum baru).
  • mpc-owner / ksr-sync / mpc-crm / mpc-pos — TIDAK — tidak terkait domain ini.

Perubahan schema

Ya — lihat mpc-dev/docs/schema/migration/025-promo-image-carousel.md:

ALTER TABLE promos ADD COLUMN IF NOT EXISTS image_url text;
ALTER TABLE promos ADD COLUMN IF NOT EXISTS display_order integer NOT NULL DEFAULT 0;

Update lanjutan (chat yang sama) — seed 5 promo bergambar + hosting gambar

  • Konten: 5 promo baru dibuat langsung di DB produksi (bukan cuma migration kosong) supaya carousel Home benar-benar terlihat: 3 Court (Morning Court Privilege 20% before 9AM, Weekend Doubles Special Rp75.000 off, Off-Peak Court Hours 15% 12-4PM) + 2 F&B (Court Cafe Refresh 20% F&B, Match Day Fuel Combo Rp30.000 off combo), display_order 0-4.
  • Gambar: 5 banner digenerate (AI image generation) dengan style konsisten — background putih polos, seluruh ilustrasi/tipografi memakai warna primary tema Elegant (#426766), ikon line-art tipis relevan tema (raket/bola, jam+net, cangkir kopi, piring), serif headline + sans-serif subtext, komposisi banner 16:9.
  • Hosting gambar — keputusan penting: folder public di mpc-admin (Next.js) ternyata di belakang auth (semua path termasuk file statis di-redirect ke /login), jadi tidak bisa dipakai untuk hotlink gambar yang harus diakses langsung oleh app member tanpa login. Solusi: tambah route publik baru GET /static/* di mpc-be (Gin router.Static("/static", "./static"), tanpa auth) yang serve file langsung dari disk server (/opt/mpc-be/static/). image_url promo memakai https://mpc-be.glenrh.com/static/promos/<file>.png.
  • Ini bukan endpoint upload — file gambar ditaruh manual via rsync ke server saat deploy; belum ada UI upload di admin (form Promo masih input image_url sebagai teks URL, sesuai desain awal di plan).
  • Deploy: rebuild mpc-be (binary + folder static/promos), restart service; migration 025 yang sebelumnya cuma ada di file dijalankan manual ke mpcdb produksi (AUTO_MIGRATE=false di server, tidak otomatis).

Perubahan kode

  • mpc-be/internal/promos/entity.go: Promo.ImageURL *string, Promo.DisplayOrder int.
  • mpc-be/internal/promos/repository.go: GetActivePromos order by display_order ASC, created_at ASC.
  • mpc-be/internal/bookings/repository.go: CleanupPastBookings — drop checked_in_at IS NULL, ganti kondisi jadi checked_out_at IS NULL (cover no-show + lupa check-out sekaligus).
  • mpc-dev/docs/api/be.md: dokumentasi image_url/display_order pada active_promos[] + endpoint publik GET /static/*.
  • mpc-be/cmd/api/main.go: tambah router.Static("/static", "./static"); folder static/promos/ isi 5 gambar promo (juga di-rsync ke /opt/mpc-be/static/promos/ di server).
  • mpc-admin/.../prisma/schema.prisma: Promo.imageUrl, Promo.displayOrder.
  • mpc-admin/.../src/app/(dashboard)/customers/promos/PromoForm.tsx + src/actions/promos.ts: input + persist field baru.
  • Android: data/remote/Dtos.kt (PromoDto.imageUrl/displayOrder), data/Models.kt (BookingCourtEntry/BookingHistoryItem.startTimeIso/endTimeIso), data/remote/Mappers.kt (parseIsoInstant), feature/home/HomeSections.kt (PromoCarousel, hapus PromoBanner), feature/home/HomeScreen.kt (relokasi Upcoming Match, gating Play, re-index AppearIn, hapus section Promotions).
  • iOS: Data/Remote/Dtos.swift (PromoDto.imageUrl/displayOrder), Data/Models.swift (BookingCourtEntry/BookingHistoryItem.startTimeIso/endTimeIso), Data/Remote/Mappers.swift (parseIsoInstant), Data/Repo/MpcRepository.swift (populate ISO fields), Features/Home/HomeSections.swift (PromoCarousel, hapus PromoBanner), Features/Home/HomeScreen.swift (relokasi Upcoming Match, gating Play via ticker 30s, hapus section Promotions).

Update lanjutan (chat yang sama) — sembunyikan Upcoming Match + pull-to-refresh

  • Upcoming Match disembunyikan: setelah realistic promo images live, kartu "Upcoming Match" (di body, di atas Quick Actions) untuk sementara disembunyikan lagi via flag (SHOW_UPCOMING_MATCH_CARD Android / showUpcomingMatchCard iOS, default false) — mudah dinyalakan kembali tanpa perlu re-implement, tidak menghapus kode. Carousel promo di header tetap tampil seperti biasa (tidak terpengaruh flag ini).
  • Pull-to-refresh — Home: Android pakai PullToRefreshBox (Material3, @OptIn(ExperimentalMaterial3Api)) membungkus konten Home; refresh memanggil ulang fungsi load yang sama dipakai saat pertama masuk (home content, open matches, upcoming booking). iOS pakai .refreshable native pada ScrollView Home, memanggil ulang loadHome().
  • Pull-to-refresh — Time Slot Booking: Android pakai PullToRefreshBox membungkus LazyColumn; refresh memanggil ulang getCourts() + availability fetch untuk kombinasi tanggal/durasi/court/mode yang sedang aktif, tanpa mereset pilihan slot/plan yang sedang dipilih (beda dari reload otomatis saat user ganti tanggal/durasi/court, yang tetap reset pilihan seperti sebelumnya). iOS pakai .refreshable pada ScrollView dalam TimeSlotScreen, fungsi refresh() baru yang mirror logika Android (refetch courts + availability tanpa reset selectedPlan/expandedStartHour).
  • Tidak ada perubahan API/schema pada update ini — murni UI state (flag hide) + client-side refetch pola yang sudah ada.

Aplikasi terdampak (update ini)

  • mpc-android — PERLU (v1.19, bullet tambahan chat ini): hide Upcoming Match card, pull-to-refresh Home + Time Slot.
  • mpc-ios — PERLU (v1.8, bullet tambahan chat ini, parity 1:1): sama seperti Android.
  • mpc-be / mpc-be-pos / mpc-admin / mpc-web / mpc-owner / ksr-sync / mpc-crm / mpc-pos — TIDAK, tidak ada perubahan API/schema/backend pada update ini.

Update lanjutan (chat yang sama) — carousel dibuat lebih tinggi + foto promo diganti total

User feedback: gambar promo generated round pertama masih dinilai jelek/kurang meyakinkan, dan carousel-nya kurang tinggi dibanding contoh referensi (banner promo cinema chain XXI/m.tix). Diminta cek project referensi mtix_lumiere (Flutter, cinema chain) untuk nilai tinggi banner yang dipakai.

  • Riset referensi: mtix_lumiere/lib/views/pages/home/widget/section/info_section.dart — banner carousel-nya pakai formula height: (MediaQuery.of(context).size.width - 32) / 3.6 + 96 (dp), yang secara efektif adalah rasio gambar ~3.6:1 plus buffer chrome tetap 96dp. Untuk lebar device umum (360-430dp) hasilnya ~185-210dp — jauh lebih tinggi dari carousel kita sebelumnya (fixed 128dp).
  • Keputusan desain: bukan replikasi literal formula (angka "+96" spesifik ke layout mereka, tidak otomatis relevan ke layout kita), tapi ambil intinya — carousel di-resize dari fixed height jadi aspect-ratio-driven (16:9), aspek rasio wide terdekat yang didukung tool image-generation kita, dan kebetulan hasil tingginya sangat mendekati angka mtix pada lebar device umum (dalam ~10dp) sekaligus otomatis adaptif ke semua ukuran device. Header Home yang membungkus carousel disesuaikan agar tinggi totalnya ikut dinamis (bukan konstanta 228.dp/228 tetap seperti sebelumnya) — 100.dp chrome tetap (padding + greeting row + spacer) + tinggi carousel yang dihitung dari lebar layar.
  • Root-cause "gambar sampah": gambar promo lama di-generate di rasio ~1.5:1 (dekat persegi), lalu di-crop ContentScale.Crop/.aspectRatio(.fill) paksa ke box yang jauh lebih wide/pendek (128dp height) — bagian besar foto (termasuk judul teks yang dibakar ke gambar) ter-crop habis, jadi kelihatan pecah/acak. Setelah box carousel di-resize ke 16:9, foto baru digenerate juga di 16:9 supaya pas tanpa crop agresif.
  • Refactor besar — teks tidak lagi dibakar ke gambar: setelah 2x percobaan image-generation untuk banner bergaya "advertising" (logo + headline + CTA pill ala referensi XXI) berulang kali menghasilkan flyer neon/cluttered (bertentangan dengan ux-high-class.mdc) atau bahkan kebocoran teks/brand pihak ketiga yang tidak diminta, diputuskan pivot: generate foto murni fotografi tanpa teks/watermark, lalu judul promo + badge diskon digambar native oleh app (Compose Text+badge Android, SwiftUI Text+badge iOS) di atas scrim gradient gelap di bagian bawah foto — berdasarkan data live Promo.name/type/value (bukan dibakar ke PNG). Konsekuensi baik: admin bisa ganti nama/harga promo dari admin site tanpa perlu generate ulang gambar; typography & warna badge otomatis konsisten brand (AppColors.Secondary/Primary, font Playfair). Dot indicator carousel dipindah ke pojok kanan-atas supaya tidak numpuk dengan teks di kiri-bawah.
  • 5 foto promo diganti total (real photography, tanpa teks, 16:9): promo-court-morning.png (aksi rally pagi), promo-court-weekend.png (dua teman jalan bareng bawa raket ke court, senyum), promo-court-offpeak.png (pemain solo di court kosong malam), promo-fnb-cafe.png (meja cafe elegan), promo-fnb-matchday.png (combo burger+fries+minuman). File di-rsync overwrite ke /opt/mpc-be/static/promos/ (path/image_url tidak berubah, jadi tidak perlu update DB).
  • Catatan proses image-generation: percobaan foto "high-five doubles" berulang kali gagal — model image-gen secara konsisten menghasilkan foto mirip liputan turnamen Premier Padel asli (nama pemain, logo sponsor Qatar Airways/Alpine/dsb ikut muncul) ketika prompt menyebut "padel doubles high-five", kemungkinan bias dataset. Diselesaikan dengan mengganti konsep visual (dua orang jalan santai bawa raket, bukan gestur high-five) supaya tidak memicu pattern tersebut.
  • Tidak ada perubahan API/schema — murni UI sizing (aspect ratio, bukan fixed dp) + rendering (badge native, bukan baked-in) + aset gambar.

Aplikasi terdampak (update ini)

  • mpc-android — PERLU: PromoCarousel aspect-ratio 16:9 + overlay native (HomeSections.kt), header height dinamis (HomeScreen.kt).
  • mpc-ios — PERLU (parity 1:1): sama, HomeSections.swift + HomeScreen.swift.
  • mpc-be — TIDAK ada perubahan kode; hanya replace file gambar di static/promos/ (url sama, konten beda) via rsync ke server produksi.
  • mpc-admin / mpc-be-pos / mpc-web / mpc-owner / ksr-sync / mpc-crm / mpc-pos — TIDAK terkait.

Update lanjutan (chat yang sama) — carousel dikecilkan 24px, tanpa ganti gambar

User feedback: carousel 16:9 masih dinilai kurang pas ukurannya (kebesaran), minta dikecilkan 24 pixel — eksplisit tanpa generate ulang gambar, cukup crop bagian bawah foto yang sudah ada.

  • Implementasi: bukan lagi Modifier.aspectRatio(16f/9f) mandiri di dalam PromoCarousel. Tinggi sekarang dihitung sekali di HomeScreen (carouselHeightDp Android / carouselHeight iOS) = (lebar layar - 48) / (16/9) - 24.dp/pt, diteruskan sebagai parameter height eksplisit ke PromoCarousel (Android: lewat HomeHeaderPromoCarousel(height = ...); iOS: HomeHeader.carouselHeightPromoCarousel(height:)). Header total (maxHeaderDp/maxHeader) otomatis ikut 24px lebih pendek karena pakai nilai yang sama.
  • Tidak ada aset baru: 5 file foto promo di static/promos/ tidak diubah sama sekali. Karena AsyncImage/SmartImage memakai ContentScale.Crop/fill-crop, box yang 24px lebih pendek otomatis memotong sedikit lebih banyak dari foto yang sama (efeknya crop tambahan di bagian bawah/atas foto, tidak ada re-generate atau re-upload).
  • Verifikasi: build Android (compileDebugKotlin) + iOS (xcodebuild) sukses; APK debug diinstall ulang di device tanpa clear data (tidak logout) dan screenshot dicek — carousel terlihat lebih pendek, promo "Off-Peak Court Hours" (15% OFF) tetap terbaca jelas, "Quick Actions" langsung kelihatan lebih banyak tanpa scroll.
  • Tidak ada perubahan API/schema/backend — murni sizing UI di kedua app.

Aplikasi terdampak (update ini)

  • mpc-android — PERLU: PromoCarousel(height:) eksplisit + HomeHeader plumbing (HomeSections.kt, HomeScreen.kt).
  • mpc-ios — PERLU (parity 1:1): PromoCarousel(height:) + HomeHeader.carouselHeight (HomeSections.swift, HomeScreen.swift).
  • mpc-be / mpc-admin / mpc-be-pos / mpc-web / mpc-owner / ksr-sync / mpc-crm / mpc-pos — TIDAK terkait, tidak ada perubahan.