ProductDetailView.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452
  1. import React, { useState, useMemo, useEffect } from "react";
  2. import { useLocation, useNavigate, Link } from "react-router-dom";
  3. import { SelectedProduct } from "../../services/types";
  4. import { Area, Package } from "../../services/product/type";
  5. import { useMutation, useQuery } from "@tanstack/react-query";
  6. import { DataCacheKey, staleTime } from "../../global/constants";
  7. import { useAppDispatch, useAppSelector } from "../../hooks/useRedux";
  8. import { startLoading, stopLoading } from "../../features/loading/loadingSlice";
  9. import { productApi } from "../../apis/productApi";
  10. import { openPopup } from "../../features/popup/popupSlice";
  11. import { get } from "http";
  12. const ProductDetailView: React.FC = () => {
  13. const location = useLocation();
  14. const navigate = useNavigate();
  15. const dispatch = useAppDispatch();
  16. const area = location.state as Area;
  17. const loading = useAppSelector((state) => state.loading);
  18. const [selectedDays, setSelectedDays] = useState<number>(null);
  19. const [selectedData, setSelectedData] = useState<string>("Unlimited");
  20. const [daysOptions, setDaysOptions] = useState<number[]>([]);
  21. const [dataOptions, setDataOptions] = useState<string[]>([]);
  22. const [daysActiveOptions, setDaysActiveOptions] = useState<number[]>([]);
  23. const [dataActiveOptions, setDataActiveOptions] = useState<string[]>([]);
  24. const [prices, setPrices] = useState<{
  25. original: string;
  26. final: string;
  27. discountPercent: string;
  28. }>({
  29. original: "0.00",
  30. final: "0.00",
  31. discountPercent: "0",
  32. });
  33. const [simType, setSimType] = useState<"eSIM" | "Physical">("eSIM");
  34. const [quantity, setQuantity] = useState<number>(1);
  35. const [packages, setPackages] = useState<Package[]>(null);
  36. if (!area) {
  37. return (
  38. <div className="min-h-screen flex items-center justify-center">
  39. <div className="text-center">
  40. <p className="text-xl font-bold mb-4">Product details missing</p>
  41. <Link to="/buy-sim" className="text-[#EE0434] font-bold underline">
  42. Go to Shop
  43. </Link>
  44. </div>
  45. </div>
  46. );
  47. }
  48. // const { data: loadPackage = [] } = useQuery<Package[]>({
  49. // queryKey: [DataCacheKey.PACKAGES],
  50. // queryFn: async (): Promise<Package[]> => {
  51. // try {
  52. // dispatch(startLoading({}));
  53. // const res = await productApi.loadPackage({
  54. // areaId: area.id,
  55. // isUnlimited: "-1",
  56. // isDaily: "-1",
  57. // });
  58. // return res.data as Package[];
  59. // } catch (error) {
  60. // console.error(error);
  61. // return []; // 🔴 bắt buộc
  62. // } finally {
  63. // dispatch(stopLoading());
  64. // }
  65. // },
  66. // staleTime: staleTime,
  67. // });
  68. useEffect(() => {
  69. getProductMutation.mutate();
  70. }, []);
  71. const getProductMutation = useMutation({
  72. mutationFn: async () => {
  73. dispatch(startLoading({}));
  74. const res = await productApi.loadPackage({
  75. areaId: area.id,
  76. isUnlimited: "-1",
  77. isDaily: "-1",
  78. });
  79. return res;
  80. },
  81. onSuccess: (data) => {
  82. dispatch(stopLoading());
  83. console.log("Get package response data:", data);
  84. if (data && data.errorCode === "0") {
  85. console.log("Get package successful");
  86. setPackages(data.data as Package[]);
  87. } else {
  88. console.error("Get package failed, no token received");
  89. }
  90. },
  91. onError: (error: any) => {
  92. dispatch(stopLoading());
  93. console.error("Get package error:", error.response.data);
  94. },
  95. });
  96. const options = useMemo(() => {
  97. console.log("Calculating options from loadPackage");
  98. const daysSet = new Set<number>();
  99. const dataSet = new Set<string>();
  100. packages.forEach((p) => {
  101. daysSet.add(p.dayDuration);
  102. dataSet.add(p.amountData.toString());
  103. });
  104. const daysArray = Array.from(daysSet).sort((a, b) => a - b);
  105. const dataArray = Array.from(dataSet).sort((a, b) => {
  106. if (a === "Unlimited") return 1;
  107. if (b === "Unlimited") return -1;
  108. return parseInt(a) - parseInt(b);
  109. });
  110. return {
  111. daysArray,
  112. dataArray,
  113. };
  114. }, [packages]);
  115. useEffect(() => {
  116. setDaysOptions(options.daysArray);
  117. setDataOptions(options.dataArray);
  118. handleSelectDay(options.daysArray[0]);
  119. handleSelectData(options.dataArray[0]);
  120. }, [options]);
  121. useEffect(() => {
  122. getPrices();
  123. }, [selectedDays, selectedData]);
  124. const handleSelectDay = (day: number) => {
  125. // filter data options based on selected day if needed
  126. const dataSet = new Set<string>();
  127. packages.forEach((p) => {
  128. if (p.dayDuration === day) {
  129. dataSet.add(p.amountData.toString());
  130. }
  131. });
  132. setSelectedDays(day);
  133. setDataActiveOptions(Array.from(dataSet));
  134. };
  135. const handleSelectData = (data: string) => {
  136. // filter day options based on selected data if needed
  137. const daysSet = new Set<number>();
  138. packages.forEach((p) => {
  139. if (p.amountData.toString() === data) {
  140. daysSet.add(p.dayDuration);
  141. }
  142. });
  143. setSelectedData(data);
  144. setDaysActiveOptions(Array.from(daysSet));
  145. };
  146. const getPrices = (quantityParam?: number) => {
  147. const quantityToUse =
  148. quantityParam !== undefined ? quantityParam : quantity;
  149. // find package based on selectedDays and selectedData
  150. const selectedPackage = packages.find(
  151. (p) =>
  152. p.dayDuration === selectedDays &&
  153. p.amountData.toString() === selectedData
  154. );
  155. if (!selectedPackage) {
  156. console.log(
  157. "No package found for the selected options " +
  158. selectedDays +
  159. " days and " +
  160. selectedData +
  161. " data"
  162. );
  163. return {
  164. original: "0.00",
  165. final: "0.00",
  166. discountPercent: "0",
  167. };
  168. }
  169. console.log(
  170. "Selected package: ",
  171. selectedPackage +
  172. " for prices calculation " +
  173. selectedPackage.sellPrice +
  174. " quantity " +
  175. quantityToUse
  176. );
  177. setPrices({
  178. original: quantityToUse * selectedPackage.displayPrice,
  179. final: quantityToUse * selectedPackage.sellPrice,
  180. discountPercent: "0",
  181. });
  182. };
  183. const handleQuantityChange = (change: number) => {
  184. const newQuantity = Math.max(1, quantity + change);
  185. setQuantity(newQuantity);
  186. console.log("Selected quantity: ", newQuantity);
  187. getPrices(newQuantity);
  188. };
  189. const handleBuyNow = async () => {
  190. // Logic for custom order or standard buy
  191. console.log("Buy now clicked");
  192. const selectedPackage = packages.find(
  193. (p) =>
  194. p.dayDuration === selectedDays &&
  195. p.amountData.toString() === selectedData
  196. );
  197. if (!selectedPackage) {
  198. alert("Please select a valid package");
  199. return;
  200. }
  201. // call logic to proceed to checkout
  202. const res = await productApi.checkout({
  203. packgId: selectedPackage.id,
  204. quantity: quantity,
  205. });
  206. if (res && res.errorCode === "0") {
  207. console.log("Checkout details loaded:", res.data);
  208. // navigate to checkout with selected options
  209. navigate("/checkout", {
  210. state: {
  211. area: area,
  212. package: selectedPackage,
  213. quantity: quantity,
  214. simType: simType,
  215. checkoutDetails: res.data,
  216. },
  217. });
  218. } else {
  219. console.error("Failed to load checkout details:", res.message);
  220. dispatch(
  221. openPopup({
  222. isSuccess: false,
  223. title: "Checkout Error",
  224. message: res.message || "Failed to proceed to checkout.",
  225. buttonText: "Close",
  226. })
  227. );
  228. }
  229. };
  230. return (
  231. <div className="bg-white min-h-screen pb-20">
  232. <div className="max-w-7xl mx-auto px-4 py-4 md:py-6">
  233. <nav className="flex items-center space-x-2 text-xs md:text-sm text-slate-500">
  234. <Link
  235. to="/"
  236. className="hover:text-[#EE0434] transition-colors text-[18px]"
  237. >
  238. Home
  239. </Link>
  240. <svg
  241. className="w-3 h-3"
  242. fill="none"
  243. stroke="currentColor"
  244. viewBox="0 0 24 24"
  245. >
  246. <path
  247. d="M9 5l7 7-7 7"
  248. strokeWidth={2.5}
  249. strokeLinecap="round"
  250. strokeLinejoin="round"
  251. />
  252. </svg>
  253. <span className="text-slate-900 font-bold text-[18px]">
  254. {area.areaName1}
  255. </span>
  256. </nav>
  257. </div>
  258. <div className="max-w-7xl mx-auto px-4 grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-12">
  259. <div className="lg:col-span-5 space-y-6">
  260. <div className="relative aspect-[3/4] rounded-[24px] md:rounded-[32px] overflow-hidden shadow-2xl group">
  261. <img
  262. src={area.imgUrl}
  263. alt={area.areaName1}
  264. className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
  265. />
  266. <div className="absolute top-6 left-6 flex items-start space-x-4">
  267. <div className="w-12 h-12 md:w-16 md:h-16 rounded-full overflow-hidden border-2 border-white/50 shadow-lg">
  268. <img
  269. src={`${area.iconUrl}`}
  270. alt={area.areaName1}
  271. className="w-full h-full object-cover scale-150"
  272. />
  273. </div>
  274. <div className="text-white">
  275. <h1 className="text-2xl md:text-4xl font-black drop-shadow-md">
  276. SIM {area.areaName1}
  277. </h1>
  278. <p className="text-sm md:text-lg font-bold text-white/90">
  279. Verified: <span className="text-[#EE0434]">High Speed</span>
  280. </p>
  281. </div>
  282. </div>
  283. </div>
  284. </div>
  285. <div className="lg:col-span-7 space-y-8 md:space-y-10 pt-4">
  286. <div className="space-y-4">
  287. <h3 className="text-lg md:text-xl font-black text-slate-900 tracking-tight">
  288. Number of days
  289. </h3>
  290. <div className="flex flex-wrap gap-3">
  291. {daysOptions.map((day) => (
  292. <button
  293. key={day}
  294. onClick={() =>
  295. daysActiveOptions.includes(day) && handleSelectDay(day)
  296. }
  297. className={`min-w-[50px] md:min-w-[70px] h-10 md:h-14 rounded-xl md:rounded-2xl font-bold text-base md:text-xl transition-all border-2 ${
  298. selectedDays === day
  299. ? "border-[#EE0434] text-white bg-[#EE0434] shadow-md"
  300. : daysActiveOptions.includes(day)
  301. ? "border-[#ffffff] text-black bg-[#ffffff] shadow-md"
  302. : "border-slate-100 text-slate-300"
  303. }`}
  304. >
  305. {day}
  306. </button>
  307. ))}
  308. </div>
  309. </div>
  310. <div className="space-y-4">
  311. <h3 className="text-lg md:text-xl font-black text-slate-900 tracking-tight">
  312. Data
  313. </h3>
  314. <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
  315. {dataOptions.map((data) => (
  316. <button
  317. key={data}
  318. onClick={() =>
  319. dataActiveOptions.includes(data) && handleSelectData(data)
  320. }
  321. className={`h-10 md:h-14 rounded-xl md:rounded-2xl font-bold text-sm md:text-xl transition-all border-2 ${
  322. selectedData === data
  323. ? "border-[#EE0434] text-white bg-[#EE0434] shadow-md"
  324. : dataActiveOptions.includes(data)
  325. ? "border-[#ffffff] text-black bg-[#ffffff] shadow-md"
  326. : "border-slate-100 text-slate-300"
  327. }`}
  328. >
  329. {data === "0" ? "Unlimited" : data + " MB"}
  330. </button>
  331. ))}
  332. </div>
  333. </div>
  334. <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
  335. <div className="space-y-4">
  336. <h3 className="text-lg md:text-xl font-black text-slate-900 tracking-tight">
  337. SIM Type
  338. </h3>
  339. <div className="flex p-1 bg-slate-50 rounded-2xl border border-slate-100">
  340. <button
  341. onClick={() => setSimType("eSIM")}
  342. className={`flex-1 py-3 md:py-4 rounded-xl font-black text-sm md:text-2xl transition-all ${
  343. simType === "eSIM"
  344. ? "bg-[#EE0434] text-white shadow-lg"
  345. : "text-slate-300"
  346. }`}
  347. >
  348. eSIM
  349. </button>
  350. <button
  351. onClick={() => setSimType("Physical")}
  352. className={`flex-1 py-3 md:py-4 rounded-xl font-black text-sm md:text-2xl transition-all ${
  353. simType === "Physical"
  354. ? "bg-[#EE0434] text-white shadow-lg"
  355. : "text-slate-300"
  356. }`}
  357. >
  358. Physical SIM
  359. </button>
  360. </div>
  361. </div>
  362. <div className="space-y-4">
  363. <h3 className="text-lg md:text-xl font-black text-slate-900 tracking-tight">
  364. Quantity
  365. </h3>
  366. <div className="flex items-center space-x-4 p-2 bg-slate-50 rounded-2xl border border-slate-100 h-[68px] md:h-[76px]">
  367. <button
  368. onClick={() => handleQuantityChange(-1)}
  369. className="w-12 h-full bg-white rounded-xl shadow-sm text-slate-600 font-bold text-2xl hover:bg-slate-100 transition-colors"
  370. >
  371. -
  372. </button>
  373. <span className="flex-1 text-center font-black text-2xl text-slate-900">
  374. {quantity}
  375. </span>
  376. <button
  377. onClick={() => handleQuantityChange(1)}
  378. className="w-12 h-full bg-white rounded-xl shadow-sm text-slate-600 font-bold text-2xl hover:bg-slate-100 transition-colors"
  379. >
  380. +
  381. </button>
  382. </div>
  383. </div>
  384. </div>
  385. <div className="bg-red-50/50 border border-[#EE0434]/20 rounded-[24px] md:rounded-[40px] p-6 md:p-10 space-y-6">
  386. <div className="flex justify-between items-end">
  387. <div className="flex items-center space-x-2">
  388. <span className="text-[#EE0434] font-black text-lg">
  389. -{prices.discountPercent}%
  390. </span>
  391. <span className="text-slate-300 font-bold text-xs line-through">
  392. {prices.original.toLocaleString("vi-VN", {
  393. minimumFractionDigits: 2,
  394. maximumFractionDigits: 2,
  395. })}{" "}
  396. {area.curency}
  397. </span>
  398. </div>
  399. <span className="text-[#EE0434] font-black text-2xl md:text-3xl">
  400. {prices.final.toLocaleString("vi-VN", {
  401. minimumFractionDigits: 2,
  402. maximumFractionDigits: 2,
  403. })}{" "}
  404. {area.curency}
  405. </span>
  406. </div>
  407. <button
  408. disabled={selectedData && selectedDays && loading.isSmallLoading}
  409. className={`w-full py-4 md:py-5 rounded-2xl font-black text-xl md:text-2xl shadow-lg transition-all flex items-center justify-center space-x-3 ${
  410. selectedData && selectedDays && !loading.isSmallLoading
  411. ? "bg-[#EE0434] text-white hover:scale-[1.01] active:scale-[0.98]"
  412. : "bg-slate-100 text-slate-300 cursor-not-allowed"
  413. }`}
  414. onClick={handleBuyNow}
  415. >
  416. {loading.isSmallLoading && (
  417. <div className="w-5 h-5 border-3 border-white/30 border-t-red-500 rounded-full animate-spin"></div>
  418. )}
  419. <span>Buy now</span>
  420. </button>
  421. </div>
  422. </div>
  423. </div>
  424. </div>
  425. );
  426. };
  427. export default ProductDetailView;