- Backend: payment_sessions + pairing_failures tables; payment.service.js and pairing-failure.service.js (new); rewritten pairing.service.js (payment-gated blast + targeted "Curhat lagi" + cancel + fallback); rewritten extension.service.js (data-driven auto-approve with offline safeguard, charge-at-approval); pricing.service.js (extension tiers without free trial); mitra-status.service.js (countAvailableMitras cached path); 60s sweeper for stale payment sessions - Backend routes: client.payment.routes, client.mitra-availability.routes, internal/failed-pairings.routes; client.chat.routes rewritten for payment-gated start + /returning + /cancel + /fallback-to-blast; internal/config.routes adds 4 new keys with Valkey invalidate publish - client_app: mitra-availability poll, payment screen + notifier, pairing notifier rewrite (PairingTargetedWaiting + PairingFailed states), targeted-waiting overlay + bestie-unavailable dialog, "Curhat lagi" CTA, failed-pairing terminal, extension via payment-session - mitra_app: PairingRequestType enum, returning-chat 20s countdown auto-dismiss, extension card "otomatis disetujui" copy - control_center: 4 new config rows in Settings, Failed Pairings page (filter + paginate + action menu), sidebar + route registered - Test infrastructure: Vitest backend (7/7 pass), Playwright CC (4/4 pass), Maestro mobile scaffold (CLI install pending) - Bugs found via Playwright + fixed: LoginPage labels not associated with inputs (a11y); backend internal CORS missing PATCH/PUT/DELETE in allow-methods (silent settings breakage in browsers since Stage 4) - Docs: phase3.7.md PRD, phase3.7-plan.md, phase3.7-questions.md (Q&A), phase3.7-testing.md (E2E checklist), phase3.7-test-run-2026-05-03.md (today's run results) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
85 lines
3.1 KiB
Dart
85 lines
3.1 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import '../../../core/chat/chat_opening_provider.dart';
|
|
import '../../../core/chat/session_closure_notifier.dart';
|
|
import '../../../core/constants.dart';
|
|
|
|
/// Extension-only pricing sheet.
|
|
///
|
|
/// Used solely for in-session extension requests; the initial pairing flow
|
|
/// goes through `/payment` instead. Free-trial is never offered for extensions.
|
|
///
|
|
/// Submit triggers [SessionClosure.requestExtension], which internally
|
|
/// runs the payment-session create+confirm and then the extend POST.
|
|
class PricingBottomSheet extends ConsumerWidget {
|
|
/// Required — the in-progress chat session id this extension targets.
|
|
final String extensionSessionId;
|
|
|
|
const PricingBottomSheet({super.key, required this.extensionSessionId});
|
|
|
|
/// Show for session extension (from chat screen).
|
|
static Future<void> showForExtension(BuildContext context, {required String sessionId}) {
|
|
return showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
builder: (_) => PricingBottomSheet(extensionSessionId: sessionId),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final pricingAsync = ref.watch(chatPricingProvider);
|
|
|
|
return pricingAsync.when(
|
|
loading: () => const SizedBox(
|
|
height: 200,
|
|
child: Center(child: CircularProgressIndicator()),
|
|
),
|
|
error: (error, _) => const SizedBox(
|
|
height: 200,
|
|
child: Center(child: Text('Gagal memuat harga. Coba lagi.')),
|
|
),
|
|
data: (pricing) => DraggableScrollableSheet(
|
|
initialChildSize: 0.6,
|
|
minChildSize: 0.4,
|
|
maxChildSize: 0.8,
|
|
expand: false,
|
|
builder: (_, scrollController) {
|
|
return Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: ListView(
|
|
controller: scrollController,
|
|
children: [
|
|
const Text(
|
|
'Perpanjang Durasi',
|
|
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 16),
|
|
// No free-trial path for extensions.
|
|
...pricing.tiers.map((tier) => Card(
|
|
child: ListTile(
|
|
title: Text(tier.label),
|
|
trailing: Text(
|
|
formatRupiah(tier.price),
|
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
|
),
|
|
onTap: () {
|
|
Navigator.of(context).pop();
|
|
ref.read(sessionClosureProvider.notifier).requestExtension(
|
|
extensionSessionId,
|
|
durationMinutes: tier.durationMinutes,
|
|
price: tier.price,
|
|
);
|
|
},
|
|
),
|
|
)),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|