diff --git a/mnemo_cards_backend/lib/api/v2/packs_api_v2.dart b/mnemo_cards_backend/lib/api/v2/packs_api_v2.dart index 394367f..80ea829 100644 --- a/mnemo_cards_backend/lib/api/v2/packs_api_v2.dart +++ b/mnemo_cards_backend/lib/api/v2/packs_api_v2.dart @@ -150,6 +150,7 @@ class PacksApiV2 { } /// Generates presigned URL if value is a UUID (object ID), otherwise returns null + /// Presigned URLs now have 7 days expiration (configured in MinioConfig.presignedUrlExpirySeconds) Future _getPresignedUrlIfUuid(String? value, String bucket) async { if (value == null || value.isEmpty) return null; if (!_isValidUuid(value)) return null; @@ -394,7 +395,8 @@ class PacksApiV2 { allVoices.addAll(voices); } - // Convert to DTOs and generate presigned URLs + // Convert to DTOs and generate presigned URLs for images + // Presigned URLs now have 7 days expiration to avoid 403 errors on cached data final cardDtos = await Future.wait( paginatedCards.map((card) async { final dto = await card.toDto( diff --git a/mnemo_cards_backend/lib/storage/minio_config.dart b/mnemo_cards_backend/lib/storage/minio_config.dart index ca3f6ad..7426264 100644 --- a/mnemo_cards_backend/lib/storage/minio_config.dart +++ b/mnemo_cards_backend/lib/storage/minio_config.dart @@ -15,8 +15,9 @@ class MinioConfig { static const String cardImagesBucket = 'card-images'; static const String voiceAudioBucket = 'voice-audio'; - // Presigned URL expiration (4 hours) - static const int presignedUrlExpirySeconds = 4 * 60 * 60; + // Presigned URL expiration (7 days) + // Increased from 4 hours to prevent 403 errors on cached card data + static const int presignedUrlExpirySeconds = 7 * 24 * 60 * 60; MinioConfig({ required this.endpoint, diff --git a/mnemo_cards_web_v2/lib/domain/services/http_repository_v2.dart b/mnemo_cards_web_v2/lib/domain/services/http_repository_v2.dart index 85f238f..fe66489 100644 --- a/mnemo_cards_web_v2/lib/domain/services/http_repository_v2.dart +++ b/mnemo_cards_web_v2/lib/domain/services/http_repository_v2.dart @@ -251,6 +251,16 @@ class HttpRepositoryV2 { originalError: error, ); break; + case 409: + // Conflict - typically means resource already exists (e.g., pack already purchased) + apiException = ServerException( + message: message.isNotEmpty + ? message + : 'Conflict: This resource already exists or is already purchased', + statusCode: 409, + originalError: error, + ); + break; case null: apiException = NetworkException( message: message.isNotEmpty ? message : 'Network error', @@ -1059,7 +1069,10 @@ class HttpRepositoryV2 { } /// Get pack purchase details (includes ad reward offers) - Future getPackBuy(String packId) async { + /// Returns a tuple with CardPackBuyDto and isPurchased flag + Future<({CardPackBuyDto packInfo, bool isPurchased})?> getPackBuy( + String packId, + ) async { try { final response = await _dio.get>( ApiConfigV2.packBuy(packId), @@ -1068,7 +1081,9 @@ class HttpRepositoryV2 { if (data == null) { return null; } - return CardPackBuyDto.fromJson(data); + final packInfo = CardPackBuyDto.fromJson(data); + final isPurchased = data['isPurchased'] as bool? ?? false; + return (packInfo: packInfo, isPurchased: isPurchased); } on DioException catch (e) { if (e.response?.statusCode == 404) { return null; diff --git a/mnemo_cards_web_v2/lib/domain/services/purchases_service.dart b/mnemo_cards_web_v2/lib/domain/services/purchases_service.dart index 7932931..f81586a 100644 --- a/mnemo_cards_web_v2/lib/domain/services/purchases_service.dart +++ b/mnemo_cards_web_v2/lib/domain/services/purchases_service.dart @@ -13,7 +13,10 @@ class PurchasesService { final HttpRepositoryV2 _httpRepository; /// Get pack purchase information (includes preview cards, price, items) - Future getPackBuy(String packId) async { + /// Returns a tuple with CardPackBuyDto and isPurchased flag + Future<({CardPackBuyDto packInfo, bool isPurchased})?> getPackBuy( + String packId, + ) async { log('Getting pack buy info for $packId', name: 'PurchasesService'); try { return await _httpRepository.getPackBuy(packId); diff --git a/mnemo_cards_web_v2/lib/domain/state/purchase_state_manager.dart b/mnemo_cards_web_v2/lib/domain/state/purchase_state_manager.dart index 3483b9b..5b76960 100644 --- a/mnemo_cards_web_v2/lib/domain/state/purchase_state_manager.dart +++ b/mnemo_cards_web_v2/lib/domain/state/purchase_state_manager.dart @@ -49,16 +49,26 @@ class PurchaseStateManager extends StateManager { emit(const PurchaseState.loading()); try { - final packInfo = await _purchasesService.getPackBuy(packId); + final result = await _purchasesService.getPackBuy(packId); - if (packInfo == null) { + if (result == null) { emit(const PurchaseState.error( message: 'Pack not found or not available for purchase', )); return; } - emit(PurchaseState.loaded(packInfo: packInfo)); + // Check if pack is already purchased + if (result.isPurchased) { + emit(PurchaseState.completed( + packInfo: result.packInfo, + message: 'This pack is already purchased. You already have access to it.', + )); + log('Pack is already purchased', name: 'PurchaseStateManager'); + return; + } + + emit(PurchaseState.loaded(packInfo: result.packInfo)); log('Pack purchase info loaded', name: 'PurchaseStateManager'); } catch (e, s) { log( diff --git a/mnemo_cards_web_v2/lib/presentation/pages/purchase/purchase_page.dart b/mnemo_cards_web_v2/lib/presentation/pages/purchase/purchase_page.dart index 7756869..cda7fdc 100644 --- a/mnemo_cards_web_v2/lib/presentation/pages/purchase/purchase_page.dart +++ b/mnemo_cards_web_v2/lib/presentation/pages/purchase/purchase_page.dart @@ -9,6 +9,7 @@ import 'package:yx_state_flutter/yx_state_flutter.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../../di/app_scope/app_scope_container.dart'; +import '../../../domain/exceptions/api_exception.dart'; import '../../../domain/state/purchase_state_manager.dart'; import '../../../utils/color_extension.dart'; import '../../widgets/error_view.dart'; @@ -119,8 +120,26 @@ class _PurchasePageState extends State { name: 'PurchasePage', ); if (mounted) { + String errorMessage = 'Purchase error: ${e.toString()}'; + + // Handle specific error cases + if (e is ServerException && e.statusCode == 409) { + errorMessage = 'This pack is already purchased. You already have access to it.'; + } else if (e is ValidationException) { + errorMessage = e.message; + } else if (e is ServerException) { + errorMessage = e.message.isNotEmpty + ? e.message + : 'Server error occurred. Please try again.'; + } + ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Purchase error: ${e.toString()}')), + SnackBar( + content: Text(errorMessage), + backgroundColor: e is ServerException && e.statusCode == 409 + ? Colors.orange + : Colors.red, + ), ); } } finally { @@ -233,7 +252,7 @@ class _PurchasePageState extends State { final theme = Theme.of(context); final packColor = packInfo.color?.asColor ?? theme.colorScheme.primary; - return Expanded( + return SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -243,12 +262,18 @@ class _PurchasePageState extends State { // Preview cards if (packInfo.cards.isNotEmpty) _PreviewCards(cards: packInfo.cards), - Expanded( - child: Column( - children: [ - ...?packInfo.items?.build(), - ],), - ), + // Items (description, features, etc.) + if (packInfo.items != null && packInfo.items!.isNotEmpty) + Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...packInfo.items!.build(), + ], + ), + ), + // Purchase button Padding( padding: const EdgeInsets.all(16.0),