Phase 5.x payment catalog + customer-app splash/register polish

Payment catalog (Phase 5.x — see requirement/phase5-payment-catalog-plan.md):
- New tables payment_method_groups + payment_methods with seed (3 groups,
  10 methods; GoPay seeded inactive pending Xendit channel confirmation).
- payment-catalog.service.js with two-layer cache (60s in-process + 1h
  Valkey) and config:invalidate pub/sub fanout. Mutator API + casing-
  tolerant findActiveMethodByCode for downstream validation.
- App-facing GET /api/client/payment-methods returns pre-grouped JSON,
  active-only, empty groups dropped server-side.
- POST /api/client/payment-requests now validates `method` against the
  catalog (INVALID_PAYMENT_METHOD 422) and stamps
  product_metadata.preferred_payment_code (upper-cased).
- Control-center /internal/payment-{groups,methods}{,/:id,/reorder}
  endpoints (full CRUD + idempotent reorder). New Payment Catalog page
  wired into the CC nav.
- Customer app renders the catalog as collapsible groups (first expanded)
  via paymentCatalogProvider; QRIS-only hardcoded fallback on 5xx so
  checkout never hard-fails. Replaces the hardcoded _PayMethod enum.
- 10 brand SVGs (~63KB) bundled in client_app/assets/payment_icons/ from
  github.com/hafidznoor/idn-finlogos. Xendit's per-channel media-asset
  pages were planned but found decommissioned during implementation —
  switched to idn-finlogos with the standard "channels-we-accept"
  trademark posture. See assets/payment_icons/README.md for the workflow
  to add new methods.
- 16 vitest cases covering the service + cache; full backend suite green
  (162/162).

Customer-app splash + register polish:
- Splash rewritten per figma S1: warm vertical gradient, two ImageFiltered
  radial orbs, 96×96 rounded-square logo tile, "HaloBestie" + "kamu gak
  harus ngerasain ini sendirian." Self-driving navigation via context.go
  after a 2.5s post-frame timer (native Android splash burns ~1-1.5s
  before Flutter paints — 1s timer yielded near-zero visible duration).
  Router early-returns null for isSplash so it never moves us off /splash
  on its own.
- 3-page onboarding carousel removed: user clarified the new splash
  REPLACES that carousel. Dropped /onboarding route, OnboardingScreen,
  onboardingDoneProvider + gating, dead splash_{1,2,3}.png + the
  splash_chat_hebat.png Flutter asset. Phase 4 /onboarding/* subroutes
  untouched; Android-native launch_background drawable left alone.
- Register screen (login-by-phone) polished: circular pink back button +
  72×72 logo badge (same brandLogoBg pink as splash, Transform.scale 1.4
  to fill the tile). Step-dots indicator removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 23:06:46 +08:00
parent d60c048776
commit 1f6d8e09ae
39 changed files with 2634 additions and 370 deletions

View File

@@ -0,0 +1,99 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_client_provider.dart';
/// One row in the payment-method catalog (server-side: `payment_methods`).
class PaymentMethodEntry {
final String id;
final String paymentCode;
final String displayName;
final String? icon;
const PaymentMethodEntry({
required this.id,
required this.paymentCode,
required this.displayName,
this.icon,
});
}
/// One group in the payment-method catalog (server-side:
/// `payment_method_groups`). Groups are already filtered + ordered by the
/// backend — render verbatim.
class PaymentMethodGroup {
final String id;
final String name;
final List<PaymentMethodEntry> methods;
const PaymentMethodGroup({
required this.id,
required this.name,
required this.methods,
});
}
class PaymentCatalog {
final List<PaymentMethodGroup> groups;
const PaymentCatalog(this.groups);
/// Whether this came from the hardcoded fallback (catalog endpoint failed).
/// UI uses this to optionally surface a "couldn't load all methods" hint.
final bool isFallback = false;
}
class _FallbackCatalog extends PaymentCatalog {
const _FallbackCatalog() : super(const [_kFallbackGroup]);
@override
bool get isFallback => true;
}
const _kFallbackGroup = PaymentMethodGroup(
id: 'fallback-paling-cepat',
name: 'Paling Cepat',
methods: [
PaymentMethodEntry(
id: 'fallback-qris',
paymentCode: 'QRIS',
displayName: 'QRIS',
icon: 'qris',
),
],
);
const PaymentCatalog kFallbackPaymentCatalog = _FallbackCatalog();
/// App-facing catalog. Calls `GET /api/client/payment-methods`; on 5xx or
/// network error returns [kFallbackPaymentCatalog] so checkout never
/// hard-fails. See `requirement/phase5-payment-catalog-plan.md` §5.
final paymentCatalogProvider = FutureProvider<PaymentCatalog>((ref) async {
final api = ref.read(apiClientProvider);
try {
final res = await api.get('/api/client/payment-methods');
final data = res['data'] as Map<String, dynamic>?;
final raw = data?['groups'] as List<dynamic>? ?? const [];
final groups = raw.map((g) {
final gm = g as Map<String, dynamic>;
final methods = (gm['methods'] as List<dynamic>? ?? const []).map((m) {
final mm = m as Map<String, dynamic>;
return PaymentMethodEntry(
id: mm['id'] as String,
paymentCode: mm['payment_code'] as String,
displayName: mm['display_name'] as String,
icon: mm['icon'] as String?,
);
}).toList(growable: false);
return PaymentMethodGroup(
id: gm['id'] as String,
name: gm['name'] as String,
methods: methods,
);
}).toList(growable: false);
// Defensive empty-catalog guard: if every group ended up empty after
// parsing, fall back so the user always sees at least QRIS.
if (groups.isEmpty || groups.every((g) => g.methods.isEmpty)) {
return kFallbackPaymentCatalog;
}
return PaymentCatalog(groups);
} catch (_) {
return kFallbackPaymentCatalog;
}
});