Commit e7dd4bc3 authored by DatHV's avatar DatHV
Browse files

update page detail

parent 8ec716d3
class APIPaths {
static const String baseUrl = "https://api.sandbox.mypoint.com.vn/8854/gup2start/rest";
static const String baseUrl = "https://api.mypoint.com.vn/8854/gup2start/rest";
static const String checkUpdate = "/version-management-service/api/v1.0/check-customer-software-update";
static const String getOnboardingInfo = "/resource/api/v2.0/intro-screen";
static const String checkPhoneNumber = "/user/api/v2.0/account/users/checkPhoneNumber";
......@@ -8,4 +8,5 @@ class APIPaths {
static const String retryOtpWithAction = "/iam/v2/authentication/otp/retry";
static const String signup = "/user/api/v2.0/signup";
static const String otpCreateNew = "/otpCreateNew/1.0.0";
static const String websitePageGetDetail = "/websitePageGetDetail/1.0.0";
}
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:flutter/material.dart';
extension PhoneValidator on String {
bool isPhoneValid() {
......@@ -9,8 +10,23 @@ extension PhoneValidator on String {
extension StringConvert on String {
String toSha256() {
var bytes1 = utf8.encode(this); // data being hashed
var bytes1 = utf8.encode(this);
var digest1 = sha256.convert(bytes1);
return digest1.toString();
}
}
\ No newline at end of file
}
/// Hàm parse hex -> Color
Color parseHexColor(String hexString, {Color fallbackColor = Colors.grey}) {
try {
if (hexString.startsWith('#')) {
hexString = hexString.replaceFirst('#', '');
}
if (hexString.length == 6) {
hexString = 'ff$hexString';
}
return Color(int.parse(hexString, radix: 16));
} catch (e) {
return fallbackColor;
}
}
......@@ -9,6 +9,7 @@ import '../model/update_response_object.dart';
import '../screen/onboarding/model/check_phone_response_model.dart';
import '../screen/onboarding/model/onboarding_info_model.dart';
import '../screen/otp/model/otp_verify_response_model.dart';
import '../screen/pageDetail/model/campaign_detail_model.dart';
import '../screen/splash/splash_screen_viewmodel.dart';
import 'model_maker.dart';
......@@ -86,4 +87,14 @@ extension RestfullAPIClientAllApi on RestfulAPIClient {
(data) => EmptyCodable.fromJson(data as Json),
);
}
Future<BaseResponseModel<CampaignDetailResponseModel>> websitePageGetDetail(String id) async {
final body = {"website_page_id": "18756", "access_token": "",};
return requestNormal(
APIPaths.websitePageGetDetail,
Method.POST,
body,
(data) => CampaignDetailResponseModel.fromJson(data as Json),
);
}
}
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:local_auth/local_auth.dart';
import 'biometric_viewmodel.dart';
class BiometricAuthScreen extends StatefulWidget {
const BiometricAuthScreen({super.key});
@override
State<BiometricAuthScreen> createState() => _BiometricAuthScreenState();
}
class _BiometricAuthScreenState extends State<BiometricAuthScreen> {
final controller = Get.put(BiometricViewModel());
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
leading: null,
title: Obx(() {
String title = controller.biometricType.value == BiometricType.face ? "Face ID" : "Touch ID";
return Text(title);
}),
backgroundColor: Colors.white,
foregroundColor: Colors.black,
elevation: 0,
),
body: Obx(() {
if (!controller.isAvailable.value) {
return const Center(child: Text("Thiết bị không hỗ trợ sinh trắc học."));
}
String title = controller.biometricType.value == BiometricType.face ? "Kích hoạt xác thực Face ID" : "Kích hoạt xác thực vân tay";
String description = controller.biometricType.value == BiometricType.face
? "Kích hoạt xác thực Face ID để đăng nhập nhanh không cần mật khẩu.\nBạn có muốn thực hiện không?"
: "Kích hoạt xác thực vân tay để đăng nhập nhanh không cần mật khẩu.\nBạn có muốn thực hiện không?";
IconData icon = controller.biometricType.value == BiometricType.face ? Icons.face : Icons.fingerprint;
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 80, color: Colors.black54),
const SizedBox(height: 20),
Text(title, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
const SizedBox(height: 10),
Text(description, textAlign: TextAlign.center, style: const TextStyle(fontSize: 16, color: Colors.black54)),
const SizedBox(height: 80),
/// Nút kích hoạt
Obx(() => SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: controller.isAuthenticating.value ? null : controller.authenticate,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 15),
backgroundColor: Colors.redAccent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
child: controller.isAuthenticating.value
? const CircularProgressIndicator(color: Colors.white)
: const Text("Kích hoạt", style: TextStyle(color: Colors.white, fontSize: 18)),
),
)),
const SizedBox(height: 10),
/// Nút để sau
TextButton(
onPressed: () => Get.back(),
child: const Text("Để sau", style: TextStyle(fontSize: 16, color: Colors.black54)),
),
],
),
),
);
}),
);
}
}
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:local_auth/local_auth.dart';
class BiometricViewModel extends GetxController {
final LocalAuthentication _localAuth = LocalAuthentication();
var biometricType = Rxn<BiometricType>(); // Loại sinh trắc học (Face ID / Touch ID)
var isAvailable = false.obs; // Kiểm tra thiết bị có hỗ trợ sinh trắc học không
var isAuthenticating = false.obs; // Trạng thái xác thực
@override
void onInit() {
super.onInit();
checkBiometricType();
}
/// Kiểm tra loại sinh trắc học có thể sử dụng
Future<void> checkBiometricType() async {
try {
bool canCheckBiometrics = await _localAuth.canCheckBiometrics;
List<BiometricType> availableBiometrics = await _localAuth.getAvailableBiometrics();
isAvailable.value = canCheckBiometrics;
if (availableBiometrics.contains(BiometricType.face)) {
biometricType.value = BiometricType.face;
} else if (availableBiometrics.contains(BiometricType.fingerprint)) {
biometricType.value = BiometricType.fingerprint;
}
} catch (e) {
print("Lỗi kiểm tra sinh trắc học: $e");
}
}
/// Xác thực sinh trắc học
Future<void> authenticate() async {
isAuthenticating.value = true;
try {
bool authenticated = await _localAuth.authenticate(
localizedReason: "Xác thực để kích hoạt đăng nhập nhanh",
options: const AuthenticationOptions(
biometricOnly: true,
stickyAuth: true,
),
);
if (authenticated) {
Get.snackbar("Thành công", "Xác thực sinh trắc học thành công!",
backgroundColor: Colors.green, colorText: Colors.white);
} else {
Get.snackbar("Thất bại", "Xác thực không thành công!",
backgroundColor: Colors.red, colorText: Colors.white);
}
} catch (e) {
print("Lỗi xác thực: $e");
}
isAuthenticating.value = false;
}
}
......@@ -20,7 +20,7 @@ class CreatePasswordScreen extends StatelessWidget {
return Scaffold(
appBar: AppBar(
centerTitle: true,
leading: CustomBackButton(onPressed: () => {Get.off(() => OnboardingScreen())}),
leading: CustomBackButton(),
),
body: SafeArea(
child: Stack(
......
......@@ -38,8 +38,9 @@ class CreatePasswordViewModel extends GetxController {
Future<void> onSubmit() async {
if (!isButtonEnabled.value) return;
try {
final success = await repository.signup(newPassword.value);
if (success) {
final response = await repository.signup(newPassword.value);
// errorMessage.value = success
if (response.isSuccess) {
errorMessage.value = "";
// TODO: Điều hướng sang màn hình tiếp theo
// e.g. Get.offAllNamed("/home");
......
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:mypoint_flutter_app/networking/restful_api_request.dart';
import '../../base/base_response_model.dart';
import '../../base/restful_api_viewmodel.dart';
import '../splash/splash_screen_viewmodel.dart';
abstract class ICreatePasswordRepository {
late String phoneNumber;
Future<bool?> createPassword(String newPassword);
Future<bool> signup(String password);
Future<BaseResponseModel<EmptyCodable>> signup(String password);
}
class SignUpCreatePasswordRepository extends RestfulApiViewModel implements ICreatePasswordRepository {
......@@ -16,11 +18,11 @@ class SignUpCreatePasswordRepository extends RestfulApiViewModel implements ICre
SignUpCreatePasswordRepository(this.phoneNumber);
@override
Future<bool> signup(String password) async {
Future<BaseResponseModel<EmptyCodable>> signup(String password) async {
showLoading();
return client.signup(phoneNumber, password).then((value) {
hideLoading();
return value.isSuccess;
return value;
});
}
......
......@@ -66,9 +66,6 @@ class LoginViewModel extends RestfulApiViewModel {
}
void onForgotPassPressed() {
client.getOnboardingInfo().then((value) {
info.value = value;
});
}
/// Xác thực đăng nhập bằng sinh trắc
......
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart'; // Hiển thị HTML
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
import 'package:get/get.dart';
import 'package:mypoint_flutter_app/base/base_response_model.dart';
import '../../base/base_screen.dart';
import '../../base/basic_state.dart';
import '../../configs/constants.dart';
import '../../resouce/base_color.dart';
import '../biometric/biometric_screen.dart';
import '../create_pass/create_pass_screen.dart';
import '../create_pass/signup_create_password_repository.dart';
import '../login/login_screen.dart';
import '../otp/otp_screen.dart';
import '../otp/verify_otp_repository.dart';
import '../pageDetail/campaign_detail_screen.dart';
import 'model/check_phone_response_model.dart';
import 'onboarding_viewmodel.dart';
......@@ -56,6 +58,8 @@ class _OnboardingScreenState extends BaseState<OnboardingScreen> with BasicState
}
void _handleResponseCheckPhoneNumber(CheckPhoneResponseModel? response) {
Get.to(CampaignDetailScreen());
return;
if (response == null) return;
if (response.requireRecaptcha == true) {
// show Captcha
......
import 'package:flutter/material.dart';
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
import 'package:get/get.dart';
import '../../extensions/string_extension.dart'; // tuỳ dự án
import '../../resouce/base_color.dart';
import '../../widgets/network_image_with_aspect_ratio.dart'; // widget custom
import 'campaign_detail_viewmodel.dart';
import 'model/campaign_detail_item_model.dart';
import 'model/campaign_detail_model.dart';
import 'model/media_type_item_campaign.dart';
class CampaignDetailScreen extends StatefulWidget {
const CampaignDetailScreen({super.key});
@override
State<CampaignDetailScreen> createState() => _CampaignDetailScreenState();
}
class _CampaignDetailScreenState extends State<CampaignDetailScreen> {
final CampaignDetailViewModel _viewModel = Get.put(CampaignDetailViewModel());
@override
void initState() {
super.initState();
_viewModel.fetchCampaignDetail();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: BaseColor.second200,
// Không dùng AppBar mặc định
body: Obx(() {
CampaignDetailModel? pageDetail = _viewModel.campaignDetail.value.data?.pageDetail;
if (pageDetail == null) {
return const Center(child: CircularProgressIndicator());
}
// Lấy các giá trị
final thumbnail = pageDetail.thumbnail ?? "";
final publishDate = pageDetail.publishDate ?? "";
final title = pageDetail.title ?? "";
final List<CampaignDetailItemModel> items = pageDetail.items ?? [];
final buttonOn = pageDetail.buttonOn ?? "0";
final buttonColor = pageDetail.buttonColor ?? "#d9d9d9";
final buttonName = pageDetail.buttonName ?? "";
final buttonTextColor = pageDetail.buttonTextColor ?? "#FFFFFF";
return Stack(
children: [
SingleChildScrollView(
child: Column(
children: [
if (thumbnail.isNotEmpty)
NetworkImageWithAspectRatio(
imageUrl: thumbnail,
placeholder: Container(
height: 200,
color: Colors.grey.shade200,
child: const Center(child: CircularProgressIndicator()),
),
errorWidget: Image.asset("assets/bg_header_campain_default.png", fit: BoxFit.cover),
)
else
Image.asset("assets/bg_header_campain_default.png", fit: BoxFit.cover),
Transform.translate(
offset: const Offset(0, -32),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: BaseColor.second200),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(publishDate, style: const TextStyle(color: Colors.grey, fontSize: 14)),
const SizedBox(height: 8),
Text(title, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
_buildItems(items),
const SizedBox(height: 24),
// 3) Nút, nếu có
if (buttonOn == "1")
ElevatedButton(
onPressed: () {
// Xử lý khi bấm nút
},
style: ElevatedButton.styleFrom(
backgroundColor: parseHexColor(buttonColor),
minimumSize: const Size.fromHeight(48),
),
child: Text(
buttonName,
style: TextStyle(color: parseHexColor(buttonTextColor), fontWeight: FontWeight.bold),
),
),
],
),
),
),
],
),
),
Positioned(
top: MediaQuery.of(context).padding.top + 8,
left: 8,
child: IconButton(
icon: const Icon(Icons.arrow_back_ios, color: Colors.white),
onPressed: () => Get.back(),
),
),
if (buttonOn == "1") _bottomButton(pageDetail),
],
);
}),
);
}
Widget _bottomButton(CampaignDetailModel? pageDetail) {
final buttonColor = pageDetail?.buttonColor ?? "#d9d9d9";
final buttonName = pageDetail?.buttonName ?? "";
final buttonTextColor = pageDetail?.buttonTextColor ?? "#FFFFFF";
return Positioned(
left: 16,
right: 16,
bottom: MediaQuery.of(context).padding.bottom + 16,
child: ElevatedButton(
onPressed: () {
// Xử lý khi bấm nút
},
style: ElevatedButton.styleFrom(
backgroundColor: parseHexColor(buttonColor),
minimumSize: const Size.fromHeight(48),
),
child: Text(buttonName, style: TextStyle(color: parseHexColor(buttonTextColor), fontWeight: FontWeight.bold)),
),
);
}
Widget _buildItems(List<CampaignDetailItemModel> items) {
List<Widget> widgets = [];
for (var item in items) {
final mediaType = item.mediaType?.value ?? ""; //toLowerCase() ?? "";
if (mediaType == MediaTypeItemCampaign.image.name) {
if (item.contentText != null && item.contentText!.isNotEmpty) {
widgets.add(
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: NetworkImageWithAspectRatio(
imageUrl: item.contentText!,
placeholder: Container(
height: 200,
color: Colors.grey.shade200,
child: const Center(child: CircularProgressIndicator()),
),
errorWidget: Image.asset("assets/bg_header_campain_default.png", fit: BoxFit.cover),
),
),
);
}
} else if (mediaType == MediaTypeItemCampaign.text.name) {
if (item.contentText != null && item.contentText!.isNotEmpty) {
widgets.add(Padding(padding: const EdgeInsets.only(bottom: 16), child: HtmlWidget(item.contentText!)));
}
}
}
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: widgets);
}
}
import 'package:get/get.dart';
import 'package:flutter/material.dart';
import 'package:mypoint_flutter_app/configs/constants.dart';
import 'package:mypoint_flutter_app/networking/restful_api_request.dart';
import '../../base/base_response_model.dart';
import '../../base/restful_api_viewmodel.dart';
import 'model/campaign_detail_model.dart';
class CampaignDetailViewModel extends RestfulApiViewModel {
var campaignDetail = BaseResponseModel<CampaignDetailResponseModel>().obs;
var isLoading = false.obs;
var errorMessage = "".obs;
void fetchCampaignDetail() {
showLoading();
isLoading(true);
client.websitePageGetDetail("").then((value) {
campaignDetail.value = value;
if (!value.isSuccess) {
errorMessage.value = value.errorMessage ?? Constants.commonError;
}
hideLoading();
isLoading(false);
});
}
}
import 'package:json_annotation/json_annotation.dart';
import 'media_type_item_campaign.dart';
part 'campaign_detail_item_model.g.dart';
@JsonSerializable()
class CampaignDetailItemModel {
@JsonKey(name: "content_caption")
String? contentCaption;
@JsonKey(name: "content_text")
String? contentText;
@JsonKey(name: "media_type", fromJson: MediaTypeItemCampaign.fromString, toJson: _mediaTypeToJson)
MediaTypeItemCampaign? mediaType;
CampaignDetailItemModel({
this.contentCaption,
this.contentText,
this.mediaType,
});
factory CampaignDetailItemModel.fromJson(Map<String, dynamic> json) =>
_$CampaignDetailItemModelFromJson(json);
Map<String, dynamic> toJson() => _$CampaignDetailItemModelToJson(this);
/// 🎯 Helper để convert Enum sang String khi serialize JSON
static String? _mediaTypeToJson(MediaTypeItemCampaign? type) => type?.toJson();
}
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'campaign_detail_item_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
CampaignDetailItemModel _$CampaignDetailItemModelFromJson(
Map<String, dynamic> json,
) => CampaignDetailItemModel(
contentCaption: json['content_caption'] as String?,
contentText: json['content_text'] as String?,
mediaType: MediaTypeItemCampaign.fromString(json['media_type'] as String?),
);
Map<String, dynamic> _$CampaignDetailItemModelToJson(
CampaignDetailItemModel instance,
) => <String, dynamic>{
'content_caption': instance.contentCaption,
'content_text': instance.contentText,
'media_type': CampaignDetailItemModel._mediaTypeToJson(instance.mediaType),
};
import 'package:json_annotation/json_annotation.dart';
import 'campaign_detail_item_model.dart';
part 'campaign_detail_model.g.dart';
@JsonSerializable()
class CampaignDetailModel {
final String? title;
@JsonKey(name: "publish_at_date")
final String? publishDate;
final String? thumbnail;
@JsonKey(name: "button_on")
final String? buttonOn;
@JsonKey(name: "button_color")
final String? buttonColor;
@JsonKey(name: "button_name")
final String? buttonName;
@JsonKey(name: "button_text_color")
final String? buttonTextColor;
final List<CampaignDetailItemModel>? items;
CampaignDetailModel({
this.title,
this.publishDate,
this.thumbnail,
this.buttonOn,
this.buttonColor,
this.buttonName,
this.buttonTextColor,
this.items,
});
factory CampaignDetailModel.fromJson(Map<String, dynamic> json) => _$CampaignDetailModelFromJson(json);
Map<String, dynamic> toJson() => _$CampaignDetailModelToJson(this);
}
@JsonSerializable()
class CampaignDetailResponseModel {
@JsonKey(name: "page_detail")
CampaignDetailModel? pageDetail;
CampaignDetailResponseModel({
this.pageDetail,
});
factory CampaignDetailResponseModel.fromJson(Map<String, dynamic> json) => _$CampaignDetailResponseModelFromJson(json);
Map<String, dynamic> toJson() => _$CampaignDetailResponseModelToJson(this);
}
\ No newline at end of file
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'campaign_detail_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
CampaignDetailModel _$CampaignDetailModelFromJson(Map<String, dynamic> json) =>
CampaignDetailModel(
title: json['title'] as String?,
publishDate: json['publish_at_date'] as String?,
thumbnail: json['thumbnail'] as String?,
buttonOn: json['button_on'] as String?,
buttonColor: json['button_color'] as String?,
buttonName: json['button_name'] as String?,
buttonTextColor: json['button_text_color'] as String?,
items:
(json['items'] as List<dynamic>?)
?.map(
(e) =>
CampaignDetailItemModel.fromJson(e as Map<String, dynamic>),
)
.toList(),
);
Map<String, dynamic> _$CampaignDetailModelToJson(
CampaignDetailModel instance,
) => <String, dynamic>{
'title': instance.title,
'publish_at_date': instance.publishDate,
'thumbnail': instance.thumbnail,
'button_on': instance.buttonOn,
'button_color': instance.buttonColor,
'button_name': instance.buttonName,
'button_text_color': instance.buttonTextColor,
'items': instance.items,
};
CampaignDetailResponseModel _$CampaignDetailResponseModelFromJson(
Map<String, dynamic> json,
) => CampaignDetailResponseModel(
pageDetail:
json['page_detail'] == null
? null
: CampaignDetailModel.fromJson(
json['page_detail'] as Map<String, dynamic>,
),
);
Map<String, dynamic> _$CampaignDetailResponseModelToJson(
CampaignDetailResponseModel instance,
) => <String, dynamic>{'page_detail': instance.pageDetail};
import 'package:get/get.dart';
import 'package:json_annotation/json_annotation.dart';
/// 🎯 Enum ánh xạ với String
enum MediaTypeItemCampaign {
image("image"),
text("text");
final String value;
const MediaTypeItemCampaign(this.value);
/// 🎯 Chuyển từ String sang Enum
static MediaTypeItemCampaign? fromString(String? value) {
return MediaTypeItemCampaign.values.firstWhereOrNull((e) => e.value == value);
}
/// 🎯 Chuyển từ Enum sang String
String toJson() => value;
}
\ No newline at end of file
import 'package:flutter/material.dart';
class NetworkImageWithAspectRatio extends StatefulWidget {
final String imageUrl;
final Widget? placeholder;
final Widget? errorWidget;
const NetworkImageWithAspectRatio({
super.key,
required this.imageUrl,
this.placeholder,
this.errorWidget,
});
@override
State<NetworkImageWithAspectRatio> createState() => _NetworkImageWithAspectRatioState();
}
class _NetworkImageWithAspectRatioState extends State<NetworkImageWithAspectRatio> {
double? _aspectRatio;
bool _hasError = false;
@override
void initState() {
super.initState();
_loadImageInfo();
}
void _loadImageInfo() {
final image = NetworkImage(widget.imageUrl);
image.resolve(const ImageConfiguration()).addListener(
ImageStreamListener(
(ImageInfo info, bool synchronousCall) {
final width = info.image.width;
final height = info.image.height;
setState(() {
_aspectRatio = width / height;
_hasError = false;
});
},
onError: (dynamic error, stackTrace) {
setState(() {
_hasError = true;
});
},
),
);
}
@override
Widget build(BuildContext context) {
// Nếu load lỗi => hiển thị errorWidget nếu có
if (_hasError) {
return widget.errorWidget ??
Container(
color: Colors.grey.shade200,
alignment: Alignment.center,
child: const Text("Load ảnh lỗi"),
);
}
// Nếu chưa có _aspectRatio => hiển thị placeholder (progress, v.v.)
if (_aspectRatio == null) {
return widget.placeholder ??
Container(
color: Colors.grey.shade200,
height: 200,
alignment: Alignment.center,
child: const CircularProgressIndicator(),
);
}
// Có aspectRatio => hiển thị ảnh với AspectRatio
return AspectRatio(
aspectRatio: _aspectRatio!,
child: Image.network(
widget.imageUrl,
fit: BoxFit.cover,
// Nếu ảnh load xong aspect ratio nhưng khi vẽ vẫn lỗi => fallback
errorBuilder: (context, error, stackTrace) {
return widget.errorWidget ??
Container(
color: Colors.grey.shade200,
alignment: Alignment.center,
child: const Text("Load ảnh lỗi"),
);
},
),
);
}
}
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment