Explorar o código

Add checkout flow, popup, and dynamic product loading

Implemented a new checkout page and flow, including customer info, payment method selection, and order confirmation. Added a global popup component and Redux slice for notifications. Refactored product and area loading to be dynamic from the API, updated Header and BuySimView to use live data, and introduced a reusable ProductCard component. Also updated constants, styles, and fixed FAQ rendering.
trunghieubui hai 4 semanas
pai
achega
8cff814789

BIN=BIN
EsimLao/esim-vite/dist.zip


+ 54 - 0
EsimLao/esim-vite/package-lock.json

@@ -1361,6 +1361,60 @@
         "node": ">=14.0.0"
       }
     },
+    "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
+      "version": "1.7.1",
+      "inBundle": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@emnapi/wasi-threads": "1.1.0",
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
+      "version": "1.7.1",
+      "inBundle": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
+      "version": "1.1.0",
+      "inBundle": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
+      "version": "1.1.0",
+      "inBundle": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@emnapi/core": "^1.7.1",
+        "@emnapi/runtime": "^1.7.1",
+        "@tybys/wasm-util": "^0.10.1"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
+      "version": "0.10.1",
+      "inBundle": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
+      "version": "2.8.1",
+      "inBundle": true,
+      "license": "0BSD",
+      "optional": true
+    },
     "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
       "version": "4.1.18",
       "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",

+ 5 - 0
EsimLao/esim-vite/src/App.tsx

@@ -12,6 +12,8 @@ import ArticleDetailView from "./pages/news/NewsDetailView";
 import LoginView from "./pages/login/LoginView";
 import ContactView from "./pages/contact/ContactView";
 import TopLoader from "./components/TopLoader";
+import CheckoutView from "./pages/checkout/CheckoutView";
+import Popup from "./components/Popup";
 
 const App: React.FC = () => {
   const location = useLocation();
@@ -31,6 +33,8 @@ const App: React.FC = () => {
     >
       {!isPlainView && <Header />}
 
+      <Popup />
+
       <div className={`flex flex-1 ${isAiView ? "overflow-hidden" : ""}`}>
         {isAiView && <Sidebar />}
 
@@ -63,6 +67,7 @@ const App: React.FC = () => {
               <Route path="/" element={<HomeView />} />
               <Route path="/buy-sim" element={<BuySimView />} />
               <Route path="/product/:country" element={<ProductDetailView />} />
+              <Route path="/checkout" element={<CheckoutView />} />
               <Route path="/support" element={<SupportView />} />
               <Route path="/news" element={<NewsView />} />
               <Route path="/news/:id" element={<ArticleDetailView />} />

+ 3 - 2
EsimLao/esim-vite/src/apis/articleApi.ts

@@ -1,4 +1,5 @@
 import {
+  LoadArticleDetailResponse,
   LoadArticleResponse,
   LoadCategoryResponse,
 } from "../services/article/types";
@@ -27,8 +28,8 @@ class ArticleApi extends BaseApi {
   }
 
   async LoadArticleDetail({ articleId }) {
-    return this.authPost<LoadArticleResponse>("/load-detail", {
-      articleId,
+    return this.authPost<LoadArticleDetailResponse>("/detail", {
+      id: articleId,
     });
   }
 }

+ 1 - 1
EsimLao/esim-vite/src/apis/authApi.ts

@@ -1,5 +1,5 @@
 import { AccountInfo } from "../services/auth/types";
-import { AreaData } from "../services/product/type";
+import { Area } from "../services/product/type";
 import { BaseApi } from "./baseApi";
 
 class AuthApi extends BaseApi {

+ 40 - 1
EsimLao/esim-vite/src/apis/productApi.ts

@@ -1,4 +1,8 @@
-import { AreaData } from "../services/product/type";
+import {
+  Area,
+  CheckoutDetailResponse,
+  ConfirmOrderResponse,
+} from "../services/product/type";
 import { BaseApi } from "./baseApi";
 
 class ProductApi extends BaseApi {
@@ -9,6 +13,41 @@ class ProductApi extends BaseApi {
   async loadArea({ isCountry = "-1", isPopular = "-1" }) {
     return this.productPost("/list_area", { isCountry, isPopular });
   }
+
+  async loadPackage({ areaId, isUnlimited = "-1", isDaily = "-1" }) {
+    return this.productPost("/list_packg", { areaId, isUnlimited, isDaily });
+  }
+
+  async checkout({ packgId, quantity }) {
+    return this.productPost<CheckoutDetailResponse>("/place_order", {
+      packgId,
+      quantity,
+    });
+  }
+
+  async confirmOrder({
+    surName,
+    lastName,
+    email,
+    phoneNumber,
+    paymentChannelId,
+    packgId,
+    quantity,
+  }) {
+    return this.productPost<ConfirmOrderResponse>("/confirm_order", {
+      surName,
+      lastName,
+      email,
+      phoneNumber,
+      paymentChannelId,
+      packgId,
+      quantity,
+    });
+  }
+
+  async loadPaymentChannel() {
+    return this.productPost("/list_payment_channel", {});
+  }
 }
 
 export const productApi = new ProductApi();

+ 5 - 3
EsimLao/esim-vite/src/app/store.ts

@@ -1,11 +1,13 @@
-import { configureStore } from '@reduxjs/toolkit';
-import loadingReducer from '../features/loading/loadingSlice';
-import areasReducer from '../features/areas/areasSlice';
+import { configureStore } from "@reduxjs/toolkit";
+import loadingReducer from "../features/loading/loadingSlice";
+import areasReducer from "../features/areas/areasSlice";
+import popupReducer from "../features/popup/popupSlice";
 
 export const store = configureStore({
   reducer: {
     loading: loadingReducer,
     areas: areasReducer,
+    popup: popupReducer,
   },
 });
 

+ 63 - 57
EsimLao/esim-vite/src/components/Header.tsx

@@ -1,6 +1,16 @@
 import React, { useState, useEffect, useRef } from "react";
 import { useNavigate, useLocation, Link } from "react-router-dom";
 import logo from "../assets/img/getgo.svg";
+import { useAppDispatch, useAppSelector } from "../hooks/useRedux";
+import { useMutation } from "@tanstack/react-query";
+import {
+  startLoading,
+  startSmallLoading,
+  stopLoading,
+  stopSmallLoading,
+} from "../features/loading/loadingSlice";
+import { productApi } from "../apis/productApi";
+import { Area } from "../services/product/type";
 
 const Header: React.FC = () => {
   const navigate = useNavigate();
@@ -16,39 +26,10 @@ const Header: React.FC = () => {
     "popular" | "region"
   >("popular");
   const [isScrolled, setIsScrolled] = useState(false);
-
+  const [products, setProducts] = useState<Area[]>([]);
+  const dispatch = useAppDispatch();
   const langMenuRef = useRef<HTMLDivElement>(null);
 
-  const countries = [
-    { name: "China", flag: "cn" },
-    { name: "Hong Kong", flag: "hk" },
-    { name: "Japan", flag: "jp" },
-    { name: "Singapore", flag: "sg" },
-    { name: "South Korea", flag: "kr" },
-    { name: "Taiwan", flag: "tw" },
-    { name: "Thailand", flag: "th" },
-    { name: "United States", flag: "us" },
-  ];
-
-  const regionList = [
-    "Americas",
-    "Asia",
-    "Asia 11 countries",
-    "Asialink 7 countries",
-    "Australia & New Zealand",
-    "EU 33 countries",
-    "EU 40 countries",
-    "Eurolink",
-    "Europe",
-    "Europe 33 countries",
-    "Hong Kong & Macau",
-    "Middle East and Africa",
-    "North America & Canada",
-    "North America A",
-    "Oceania",
-    "Singapore & Malaysia",
-  ];
-
   const guideItems = [
     { label: "What is eSIM", path: "/support" },
     { label: "Installation instructions", path: "/support" },
@@ -61,6 +42,33 @@ const Header: React.FC = () => {
     { code: "vi", label: "Tiếng Việt", flag: "vn" },
   ];
 
+  // load product by country/region or popularity
+  const getProductMutation = useMutation({
+    mutationFn: async () => {
+      dispatch(startLoading({}));
+      const res = await productApi.loadArea(
+        activeDesktopTab === "popular"
+          ? { isCountry: "-1", isPopular: "1" }
+          : { isCountry: "1", isPopular: "1" }
+      );
+      setProducts(res.data);
+      return res;
+    },
+    onSuccess: (data) => {
+      dispatch(stopLoading());
+      console.log("Get otp response data:", data);
+      if (data && data.errorCode === "0") {
+        console.log("Get otp successful");
+      } else {
+        console.error("Get otp failed, no token received");
+      }
+    },
+    onError: (error: any) => {
+      dispatch(stopLoading());
+      console.error("Get otp error:", error.response.data);
+    },
+  });
+
   useEffect(() => {
     const handleScroll = () => {
       setIsScrolled(window.scrollY > 300);
@@ -69,17 +77,11 @@ const Header: React.FC = () => {
     return () => window.removeEventListener("scroll", handleScroll);
   }, []);
 
-  const handleCountryClick = (c: { name: string; flag: string }) => {
-    navigate(`/product/${c.name.toLowerCase()}`, {
-      state: { country: c.name, flag: c.flag },
-    });
-    setIsBuySimMegaVisible(false);
-    setIsMenuOpen(false);
-  };
-
-  const handleRegionClick = (region: string) => {
-    navigate(`/product/${region.toLowerCase()}`, {
-      state: { country: region, flag: "un" },
+  const handleAreaClick = (c: { id: number }) => {
+    navigate(`/product/${c.id}`, {
+      state: {
+        ...c,
+      },
     });
     setIsBuySimMegaVisible(false);
     setIsMenuOpen(false);
@@ -111,6 +113,10 @@ const Header: React.FC = () => {
 
   const isActive = (path: string) => location.pathname === path;
 
+  useEffect(() => {
+    getProductMutation.mutate();
+  }, [activeDesktopTab]);
+
   return (
     <>
       <header className="sticky top-0 z-[60] w-full bg-white border-b border-slate-100 shadow-sm transition-all duration-300">
@@ -263,31 +269,31 @@ const Header: React.FC = () => {
 
                       {activeDesktopTab === "popular" ? (
                         <div className="grid grid-cols-4 gap-y-8 gap-x-4">
-                          {countries.map((c) => (
+                          {products.map((c) => (
                             <div
-                              key={c.name}
-                              onClick={() => handleCountryClick(c)}
+                              key={c.areaName1}
+                              onClick={() => handleAreaClick(c)}
                               className="flex items-center space-x-3 group cursor-pointer hover:bg-slate-50 p-2 rounded-xl transition-colors"
                             >
                               <div className="w-7 h-7 rounded-full overflow-hidden border border-slate-200 shadow-sm shrink-0">
                                 <img
-                                  src={`https://flagcdn.com/w40/${c.flag}.png`}
-                                  alt={c.name}
+                                  src={`${c.iconUrl}`}
+                                  alt={c.areaName1}
                                   className="w-full h-full object-cover"
                                 />
                               </div>
                               <span className="text-[16px] font-bold text-slate-700 group-hover:text-[#EE0434] transition-colors">
-                                {c.name}
+                                {c.areaName1}
                               </span>
                             </div>
                           ))}
                         </div>
                       ) : (
                         <div className="grid grid-cols-4 gap-y-6 gap-x-2">
-                          {regionList.map((region) => (
+                          {products.map((region) => (
                             <div
                               key={region}
-                              onClick={() => handleRegionClick(region)}
+                              onClick={() => handleAreaClick(region)}
                               className="flex items-center space-x-3 group cursor-pointer hover:bg-slate-50 p-2 rounded-xl transition-colors min-w-0"
                             >
                               <div className="w-7 h-7 rounded-full bg-red-50 flex items-center justify-center shrink-0 border border-red-100/50">
@@ -300,7 +306,7 @@ const Header: React.FC = () => {
                                 </svg>
                               </div>
                               <span className="text-[16px] font-bold text-slate-700 group-hover:text-[#EE0434] transition-colors truncate">
-                                {region}
+                                {region.areaName1}
                               </span>
                             </div>
                           ))}
@@ -609,19 +615,19 @@ const Header: React.FC = () => {
                     >
                       View All Destinations →
                     </button>
-                    {countries.map((c) => (
+                    {products.map((c) => (
                       <button
-                        key={c.name}
-                        onClick={() => handleCountryClick(c)}
+                        key={c.areaName1}
+                        onClick={() => handleAreaClick(c)}
                         className="flex flex-col items-center justify-center space-y-2 py-5 rounded-2xl bg-white border border-slate-100 shadow-sm active:bg-slate-50"
                       >
                         <img
-                          src={`https://flagcdn.com/w80/${c.flag}.png`}
-                          alt={c.name}
+                          src={`${c.iconUrl}`}
+                          alt={c.areaName1}
                           className="w-10 h-10 rounded-full object-cover border-2 border-slate-50"
                         />
                         <span className="text-sm font-bold text-slate-700">
-                          {c.name}
+                          {c.areaName1}
                         </span>
                       </button>
                     ))}

+ 90 - 0
EsimLao/esim-vite/src/components/Popup.tsx

@@ -0,0 +1,90 @@
+import React from "react";
+import { useAppDispatch, useAppSelector } from "../hooks/useRedux";
+import { closePopup } from "../features/popup/popupSlice";
+
+const Popup = () => {
+  const popup = useAppSelector((state) => state.popup);
+  const dispatch = useAppDispatch();
+  if (!popup.isOpen) return null;
+  return (
+    <div className="fixed inset-0 z-[100] flex items-center justify-center p-4 animate-in fade-in duration-200">
+      {/* Backdrop */}
+      <div
+        className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm transition-opacity"
+        onClick={() => {
+          dispatch(closePopup());
+        }}
+      ></div>
+
+      {/* Modal Content */}
+      <div className="relative bg-white rounded-[40px] p-8 md:p-10 w-full max-w-sm shadow-2xl transform transition-all animate-in zoom-in-95 duration-200 scale-100 flex flex-col items-center text-center">
+        {/* Icon */}
+        <div
+          className={`w-20 h-20 rounded-full flex items-center justify-center mb-6 shadow-lg ${
+            popup.isSuccess
+              ? "bg-green-50 text-[#00c087]"
+              : "bg-red-50 text-[#EE0434]"
+          }`}
+        >
+          {popup.isSuccess ? (
+            <svg
+              className="w-10 h-10"
+              fill="none"
+              stroke="currentColor"
+              viewBox="0 0 24 24"
+            >
+              <path
+                strokeLinecap="round"
+                strokeLinejoin="round"
+                strokeWidth={3}
+                d="M5 13l4 4L19 7"
+              />
+            </svg>
+          ) : (
+            <svg
+              className="w-10 h-10"
+              fill="none"
+              stroke="currentColor"
+              viewBox="0 0 24 24"
+            >
+              <path
+                strokeLinecap="round"
+                strokeLinejoin="round"
+                strokeWidth={3}
+                d="M6 18L18 6M6 6l12 12"
+              />
+            </svg>
+          )}
+        </div>
+
+        {/* Text */}
+        <h3
+          className={`text-2xl font-black mb-3 tracking-tight ${
+            popup.isSuccess ? "text-slate-900" : "text-[#EE0434]"
+          }`}
+        >
+          {popup.title}
+        </h3>
+        <p className="text-slate-500 font-medium leading-relaxed mb-8">
+          {popup.message}
+        </p>
+
+        {/* Button */}
+        <button
+          onClick={() => {
+            dispatch(closePopup());
+          }}
+          className={`w-full py-4 rounded-2xl font-black text-lg shadow-lg active:scale-95 transition-all ${
+            popup.isSuccess
+              ? "bg-[#00c087] hover:bg-[#00a876] text-white shadow-green-200"
+              : "bg-[#EE0434] hover:bg-[#d10029] text-white shadow-red-200"
+          }`}
+        >
+          {popup.buttonText}
+        </button>
+      </div>
+    </div>
+  );
+};
+
+export default Popup;

+ 39 - 0
EsimLao/esim-vite/src/components/ProductCard.tsx

@@ -0,0 +1,39 @@
+import React from "react";
+
+import { SimProduct } from "../services/types";
+import { Area } from "../services/product/type";
+
+const ProductCard: React.FC<{
+  p: Area;
+  onClick: (p: Area) => void;
+}> = ({ p, onClick }) => (
+  <div
+    onClick={() => onClick(p)}
+    className="bg-white border border-slate-100 rounded-[20px] p-3 md:p-6 shadow-sm hover:shadow-lg transition-all relative flex flex-col md:flex-row items-center md:space-x-4 space-y-2 md:space-y-0 group cursor-pointer text-center md:text-left"
+  >
+    <div className="absolute top-0 right-0 bg-[#EE0434] text-white text-[10px] font-black px-2 py-1 md:px-3 md:py-1.5 rounded-tr-[19px] rounded-bl-[14px] absolute-right-18">
+      {p.promotionPercent}%
+    </div>
+    <div className="w-10 h-10 md:w-12 md:h-12 rounded-full overflow-hidden shrink-0 border border-slate-50 bg-slate-50">
+      <img
+        src={`${p.iconUrl}`}
+        alt={p.areaName1}
+        className="w-full h-full object-cover scale-150"
+      />
+    </div>
+    <div className="flex-1 w-full">
+      <h3 className="text-slate-900 font-black text-sm md:text-lg mb-0.5 group-hover:text-[#EE0434] transition-colors truncate px-1">
+        {p.areaName1}
+      </h3>
+      <p className="text-[#EE0434] font-bold text-xs md:text-lg">
+        from:{" "}
+        <span className="font-black">
+          {p.curency}
+          {p.minDisplayPrice}
+        </span>
+      </p>
+    </div>
+  </div>
+);
+
+export default ProductCard;

+ 16 - 16
EsimLao/esim-vite/src/features/areas/areasSlice.ts

@@ -1,22 +1,22 @@
-import { createSlice, PayloadAction } from '@reduxjs/toolkit';
-import loadingSlice from '../loading/loadingSlice';
-import { AreaData } from '../../services/product/type';
+import { createSlice, PayloadAction } from "@reduxjs/toolkit";
+import loadingSlice from "../loading/loadingSlice";
+import { Area } from "../../services/product/type";
 
 const areaSlice = createSlice({
-    name: 'areas',
-    initialState: {
-        areas: [] as AreaData[],
+  name: "areas",
+  initialState: {
+    areas: [] as Area[],
+  },
+  reducers: {
+    setAreas: (state, action: PayloadAction<any>) => {
+      console.log("Setting areas state to: ", action.payload);
+      return {
+        ...state,
+        areas: action.payload,
+      };
     },
-    reducers: {
-        setAreas: (state, action: PayloadAction<any>) => {
-            console.log("Setting areas state to: ", action.payload);
-            return {
-                ...state,
-                areas: action.payload,
-            };
-        },
-    }
+  },
 });
 
 export const { setAreas } = areaSlice.actions;
-export default areaSlice.reducer;
+export default areaSlice.reducer;

+ 30 - 0
EsimLao/esim-vite/src/features/popup/popupSlice.ts

@@ -0,0 +1,30 @@
+import { createSlice, PayloadAction } from "@reduxjs/toolkit";
+import loadingSlice from "../loading/loadingSlice";
+
+const popupSlice = createSlice({
+  name: "popup",
+  initialState: {
+    isOpen: false,
+    isSuccess: true,
+    title: "Notification",
+    message: "",
+    buttonText: "OK",
+  },
+  reducers: {
+    openPopup: (state, action: PayloadAction<any>) => {
+      return {
+        ...state,
+        isOpen: true,
+        isSuccess: action.payload?.isSuccess ?? true,
+        title: action.payload?.title ?? "Notification",
+        message: action.payload?.message,
+      };
+    },
+    closePopup: (state) => {
+      state.isOpen = false;
+    },
+  },
+});
+
+export const { openPopup, closePopup } = popupSlice.actions;
+export default popupSlice.reducer;

+ 2 - 0
EsimLao/esim-vite/src/global/constants.ts

@@ -13,4 +13,6 @@ export const DataCacheKey = {
   BANNERS: "banners",
   REVIEWS: "reviews",
   FAQS: "faqs",
+  PACKAGES: "packages",
+  PAYMENT_CHANNELS: "payment_channels",
 };

+ 12 - 1
EsimLao/esim-vite/src/index.css

@@ -1 +1,12 @@
-@import "tailwindcss";
+@import "tailwindcss";
+
+.absolute-right-18 {
+    right: -18px;
+  }
+
+/* max 768px screen */
+@media (max-width: 768px) {
+  .absolute-right-18 {
+    right: 0px;
+  }
+}

+ 92 - 49
EsimLao/esim-vite/src/pages/buy-sim/BuySimView.tsx

@@ -1,57 +1,80 @@
+import React from "react";
+import { ViewMode, SimProduct, SelectedProduct } from "../../services/types";
+import ProductCard from "../../components/ProductCard";
+import { useMutation, useQuery } from "@tanstack/react-query";
+import { startLoading, stopLoading } from "../../features/loading/loadingSlice";
+import { useAppDispatch } from "../../hooks/useRedux";
+import { productApi } from "../../apis/productApi";
+import { Area } from "../../services/product/type";
+import { DataCacheKey, staleTime } from "../../global/constants";
+import { useNavigate } from "react-router-dom";
 
-import React from 'react';
-import { useNavigate, Link } from 'react-router-dom';
-import { SimProduct } from '../types';
+interface BuySimViewProps {
+  onProductSelect: (product: SelectedProduct) => void;
+  onViewChange: (view: ViewMode) => void;
+}
 
-const BuySimView: React.FC = () => {
+const BuySimView: React.FC<BuySimViewProps> = ({
+  onProductSelect,
+  onViewChange,
+}) => {
+  const dispatch = useAppDispatch();
   const navigate = useNavigate();
-  const popularSims: SimProduct[] = [
-    { id: 'p1', country: 'China', flag: 'cn', originalPrice: '$0.94', discountPrice: '$0.85', discountLabel: '-10%' },
-    { id: 'p2', country: 'Hong Kong', flag: 'hk', originalPrice: '$4.43', discountPrice: '$4.21', discountLabel: '-5%' },
-    { id: 'p3', country: 'Japan', flag: 'jp', originalPrice: '$1.44', discountPrice: '$1.3', discountLabel: '-10%' },
-    { id: 'p4', country: 'Singapore', flag: 'sg', originalPrice: '$4.43', discountPrice: '$4.21', discountLabel: '-5%' },
-    { id: 'p5', country: 'South Korea', flag: 'kr', originalPrice: '$0.94', discountPrice: '$0.85', discountLabel: '-10%' },
-    { id: 'p6', country: 'Taiwan', flag: 'tw', originalPrice: '$2.04', discountPrice: '$1.84', discountLabel: '-10%' },
-    { id: 'p7', country: 'Thailand', flag: 'th', originalPrice: '$1.89', discountPrice: '$1.78', discountLabel: '-6%' },
-    { id: 'p8', country: 'United States', flag: 'us', originalPrice: '$2.16', discountPrice: '$1.94', discountLabel: '-10%' },
-  ];
+  // load product by country/region or popularity
 
-  const handleSelect = (p: SimProduct) => {
-    navigate(`/product/${p.country.toLowerCase()}`, { state: {
-      country: p.country,
-      flag: p.flag,
-      image: p.isRegion 
-        ? 'https://images.unsplash.com/photo-1526772662000-3f88f10c053b?q=80&w=1000&auto=format&fit=crop'
-        : (p.country === 'Thailand' 
-           ? 'https://images.unsplash.com/photo-1528181304800-2f140819898f?q=80&w=1000&auto=format&fit=crop' 
-           : `https://images.unsplash.com/photo-1526772662000-3f88f10c053b?q=80&w=1000&auto=format&fit=crop`)
-    }});
-  };
+  const { data: loadArea = [] } = useQuery<Area[]>({
+    queryKey: [DataCacheKey.AREAS],
+    queryFn: async (): Promise<Area[]> => {
+      try {
+        dispatch(startLoading({}));
+        const res = await productApi.loadArea({
+          isCountry: "-1",
+          isPopular: "1",
+        });
+        // save to redux store
+        return res.data as Area[];
+      } catch (error) {
+        console.error(error);
+        return []; // 🔴 bắt buộc
+      } finally {
+        dispatch(stopLoading());
+      }
+    },
+    staleTime: staleTime,
+  });
 
-  const ProductCard: React.FC<{ p: SimProduct }> = ({ p }) => (
-    <div 
-      onClick={() => handleSelect(p)}
-      className="bg-white border border-slate-100 rounded-[20px] p-4 md:p-6 shadow-sm hover:shadow-lg transition-all relative flex items-center space-x-4 group cursor-pointer"
-    >
-      <div className="absolute top-0 right-0 bg-[#EE0434] text-white text-[10px] font-black px-3 py-1.5 rounded-tr-[19px] rounded-bl-[14px]">
-        {p.discountLabel}
-      </div>
-      <div className="w-12 h-12 rounded-full overflow-hidden shrink-0 border border-slate-50 bg-slate-50">
-        <img src={`https://flagcdn.com/w160/${p.flag}.png`} alt={p.country} className="w-full h-full object-cover scale-150" />
-      </div>
-      <div className="flex-1">
-        <h3 className="text-slate-900 font-black text-base md:text-lg mb-0.5 group-hover:text-[#EE0434] transition-colors">{p.country}</h3>
-        <p className="text-[#EE0434] font-bold text-sm md:text-lg">from: <span className="font-black">{p.discountPrice}</span></p>
-      </div>
-    </div>
-  );
+  const handleSelect = (p: Area) => {
+    // open product detail view
+    navigate(`/product/${p.id}`, {
+      state: {
+        ...p,
+      },
+    });
+  };
 
   return (
     <div className="bg-[#fcfdfe] min-h-screen">
       <div className="max-w-7xl mx-auto px-4 py-4 md:py-6 border-b border-slate-50">
         <nav className="flex items-center space-x-2 text-xs md:text-sm text-slate-500">
-          <Link to="/" className="hover:text-[#EE0434]">Home</Link>
-          <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M9 5l7 7-7 7" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round"/></svg>
+          <button
+            onClick={() => onViewChange(ViewMode.HOME)}
+            className="hover:text-[#EE0434]"
+          >
+            Home
+          </button>
+          <svg
+            className="w-3 h-3"
+            fill="none"
+            stroke="currentColor"
+            viewBox="0 0 24 24"
+          >
+            <path
+              d="M9 5l7 7-7 7"
+              strokeWidth={2.5}
+              strokeLinecap="round"
+              strokeLinejoin="round"
+            />
+          </svg>
           <span className="text-slate-900 font-bold">Buy Sim</span>
         </nav>
       </div>
@@ -59,16 +82,36 @@ const BuySimView: React.FC = () => {
       <div className="max-w-7xl mx-auto px-4 py-8 md:py-12">
         <div className="flex justify-center mb-12 md:mb-20">
           <div className="w-full max-w-3xl relative group">
-            <input type="text" placeholder="Choose country you're going to" className="w-full bg-white rounded-full py-4 md:py-6 px-10 md:px-12 text-base md:text-xl text-slate-700 shadow-sm border border-slate-100 outline-none focus:ring-4 focus:ring-red-50 transition-all placeholder:text-slate-300" />
+            <input
+              type="text"
+              placeholder="Choose country you're going to"
+              className="w-full bg-white rounded-full py-4 md:py-6 px-10 md:px-12 text-base md:text-xl text-slate-700 shadow-sm border border-slate-100 outline-none focus:ring-4 focus:ring-red-50 transition-all placeholder:text-slate-300"
+            />
             <button className="absolute right-2 md:right-3 top-2 md:top-3 bottom-2 md:bottom-3 aspect-square bg-[#EE0434] rounded-full flex items-center justify-center text-white shadow-lg shadow-red-200 hover:scale-105 transition-all">
-              <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round"/></svg>
+              <svg
+                className="w-5 h-5"
+                fill="none"
+                stroke="currentColor"
+                viewBox="0 0 24 24"
+              >
+                <path
+                  d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
+                  strokeWidth={2.5}
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                />
+              </svg>
             </button>
           </div>
         </div>
         <section className="mb-12">
-          <h2 className="text-xl md:text-[32px] font-black text-slate-900 mb-6 md:mb-8 tracking-tight">SIM Popular</h2>
-          <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6">
-            {popularSims.map(p => <ProductCard key={p.id} p={p} />)}
+          <h2 className="text-xl md:text-[32px] font-black text-slate-900 mb-6 md:mb-8 tracking-tight">
+            SIM Popular
+          </h2>
+          <div className="grid grid-cols-2 lg:grid-cols-4 gap-3 md:gap-6">
+            {loadArea.map((p) => (
+              <ProductCard key={p.id} p={p} onClick={handleSelect} />
+            ))}
           </div>
         </section>
       </div>

+ 461 - 0
EsimLao/esim-vite/src/pages/checkout/CheckoutView.tsx

@@ -0,0 +1,461 @@
+import React, { useState, useEffect } from "react";
+import { ViewMode } from "../../services/types";
+import { useLocation, useNavigate } from "react-router-dom";
+import { useQuery } from "@tanstack/react-query";
+import {
+  Area,
+  CheckoutDetailResponse,
+  Package,
+  PaymentChannel,
+} from "../../services/product/type";
+import { DataCacheKey, staleTime } from "../../global/constants";
+import {
+  startLoading,
+  startSmallLoading,
+  stopLoading,
+  stopSmallLoading,
+} from "../../features/loading/loadingSlice";
+import { useAppDispatch, useAppSelector } from "../../hooks/useRedux";
+import { productApi } from "../../apis/productApi";
+import { openPopup } from "../../features/popup/popupSlice";
+
+const CheckoutView = () => {
+  const navigate = useNavigate();
+  const dispatch = useAppDispatch();
+  const loading = useAppSelector((state) => state.loading);
+  const [paymentMethod, setPaymentMethod] = useState("card");
+  const [form, setForm] = useState({
+    firstName: "",
+    lastName: "",
+    email: "",
+    confirmEmail: "",
+    phone: "",
+  });
+  const [agreements, setAgreements] = useState({
+    terms: false,
+    esim: false,
+    vatInvoice: false,
+  });
+  // get data from previous step
+  const location = useLocation();
+  const state = location.state as {
+    area: Area;
+    package: Package;
+    quantity: number;
+    simType: "eSIM" | "Physical";
+    checkoutDetails: CheckoutDetailResponse;
+  };
+
+  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+    const { name, value } = e.target;
+    setForm((prev) => ({ ...prev, [name]: value }));
+  };
+
+  const handleSubmit = async () => {
+    console.log(form);
+    if (form.email !== form.confirmEmail) {
+      dispatch(
+        openPopup({
+          isSuccess: false,
+          title: "Error",
+          message: "Email and Confirm Email do not match.",
+        })
+      );
+      return;
+    }
+    // proceed to payment
+    dispatch(startSmallLoading());
+    const res = await productApi.confirmOrder({
+      surName: form.lastName,
+      lastName: form.firstName,
+      email: form.email,
+      phoneNumber: form.phone,
+      paymentChannelId: paymentMethod,
+      packgId: state.package.id,
+      quantity: state.quantity,
+    });
+    console.log("Confirm order response:", res);
+    dispatch(stopSmallLoading());
+    if (res.errorCode === "0") {
+      // open other website to pay
+      window.location.href = res.data.paymentUrl;
+    } else {
+      dispatch(
+        openPopup({
+          isSuccess: false,
+          title: "Error",
+          message: res.message || "Failed to confirm order.",
+        })
+      );
+    }
+  };
+  const handleCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+    const { name, checked } = e.target;
+    setAgreements((prev) => ({
+      ...prev,
+      [name]: checked,
+    }));
+  };
+  const { data: loadPaymentChannel = [] } = useQuery<PaymentChannel[]>({
+    queryKey: [DataCacheKey.PAYMENT_CHANNELS],
+    queryFn: async (): Promise<PaymentChannel[]> => {
+      try {
+        dispatch(startLoading({}));
+        const res = await productApi.loadPaymentChannel();
+        // save to redux store
+        return res.data as PaymentChannel[];
+      } catch (error) {
+        console.error(error);
+        return []; // 🔴 bắt buộc
+      } finally {
+        dispatch(stopLoading());
+      }
+    },
+    staleTime: staleTime,
+  });
+
+  useEffect(() => {
+    setPaymentMethod(loadPaymentChannel[0]?.id);
+    dispatch(stopSmallLoading());
+  }, [loadPaymentChannel]);
+
+  // Styles matching LoginView inputs
+  const inputClass =
+    "w-full bg-slate-50 border-2 border-transparent focus:border-[#EE0434]/20 rounded-2xl py-3 md:py-4 px-5 focus:outline-none focus:bg-white transition-all text-slate-700 font-bold placeholder:text-slate-300";
+  const labelClass =
+    "block text-slate-400 font-black text-[14px] uppercase tracking-[0.2em] pl-1 mb-2";
+
+  if (!state.checkoutDetails) {
+    return null;
+  }
+  return (
+    <div className="bg-white min-h-screen pb-20">
+      {/* Header / Breadcrumb */}
+      <div className="max-w-7xl mx-auto px-4 py-4 md:py-6 border-b border-slate-50">
+        <div className="flex items-center space-x-2">
+          <button
+            onClick={() => navigate(-1)}
+            className="flex items-center text-slate-900 font-bold hover:text-[#EE0434] transition-colors"
+          >
+            <svg
+              className="w-5 h-5 mr-1"
+              fill="none"
+              stroke="currentColor"
+              viewBox="0 0 24 24"
+            >
+              <path
+                strokeLinecap="round"
+                strokeLinejoin="round"
+                strokeWidth={2.5}
+                d="M15 19l-7-7 7-7"
+              />
+            </svg>
+            Continue shopping
+          </button>
+        </div>
+      </div>
+
+      <div className="max-w-3xl mx-auto px-4 py-8 md:py-12">
+        {/* Progress Steps */}
+        <div className="flex justify-center mb-12">
+          <div className="flex items-center w-full max-w-sm text-xs font-bold">
+            <div className="flex flex-col items-center gap-2">
+              <div className="w-8 h-8 rounded-full bg-[#00c087] text-white flex items-center justify-center">
+                <svg
+                  className="w-4 h-4"
+                  fill="none"
+                  stroke="currentColor"
+                  viewBox="0 0 24 24"
+                >
+                  <path
+                    strokeLinecap="round"
+                    strokeLinejoin="round"
+                    strokeWidth={3}
+                    d="M5 13l4 4L19 7"
+                  />
+                </svg>
+              </div>
+              <span className="text-[#00c087] text-[14px]">Choose Product</span>
+            </div>
+            <div className="flex-1 h-0.5 bg-slate-200 mx-2 mb-6"></div>
+            <div className="flex flex-col items-center gap-2">
+              <div className="w-8 h-8 rounded-full bg-[#0071e3] text-white flex items-center justify-center">
+                2
+              </div>
+              <span className="text-[#0071e3] text-center text-[14px]">
+                Order Information
+              </span>
+            </div>
+            <div className="flex-1 h-0.5 bg-slate-200 mx-2 mb-6"></div>
+            <div className="flex flex-col items-center gap-2">
+              <div className="w-8 h-8 rounded-full bg-white border border-slate-200 text-slate-300 flex items-center justify-center">
+                3
+              </div>
+              <span className="text-slate-300 text-[14px]">Payment</span>
+            </div>
+          </div>
+        </div>
+
+        <div className="space-y-10">
+          {/* Customer Information */}
+          <div>
+            <h2 className="text-xl font-black text-[#003459] mb-6">
+              Customer Information
+            </h2>
+            <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
+              <div className="space-y-1">
+                <label className={labelClass}>
+                  Last Name <span className="text-red-500">*</span>
+                </label>
+                <input
+                  type="text"
+                  className={inputClass}
+                  placeholder="Doe"
+                  onChange={handleChange}
+                  value={form.lastName}
+                  name="lastName"
+                />
+              </div>
+              <div className="space-y-1">
+                <label className={labelClass}>
+                  First Name <span className="text-red-500">*</span>
+                </label>
+                <input
+                  type="text"
+                  className={inputClass}
+                  placeholder="John"
+                  onChange={handleChange}
+                  value={form.firstName}
+                  name="firstName"
+                />
+              </div>
+              <div className="space-y-1">
+                <label className={labelClass}>
+                  Email <span className="text-red-500">*</span>
+                </label>
+                <input
+                  type="email"
+                  className={inputClass}
+                  placeholder="john@example.com"
+                  onChange={handleChange}
+                  value={form.email}
+                  name="email"
+                />
+              </div>
+              <div className="space-y-1">
+                <label className={labelClass}>
+                  Confirm Email <span className="text-red-500">*</span>
+                </label>
+                <input
+                  type="email"
+                  className={inputClass}
+                  placeholder="john@example.com"
+                  onChange={handleChange}
+                  value={form.confirmEmail}
+                  name="confirmEmail"
+                />
+              </div>
+              <div className="md:col-span-2 space-y-1">
+                <label className={labelClass}>Phone Number</label>
+                <input
+                  type="tel"
+                  className={inputClass}
+                  placeholder="+1 234 567 890"
+                  onChange={handleChange}
+                  value={form.phone}
+                  name="phone"
+                />
+              </div>
+            </div>
+          </div>
+
+          {/* Order Info */}
+          <div className="bg-slate-50/50 rounded-3xl p-6 border border-slate-100">
+            <h2 className="text-xl font-black text-[#003459] mb-6">
+              Order Info
+            </h2>
+            <div className="flex justify-between items-center">
+              <div className="flex items-center gap-4">
+                <img
+                  src={`${state.area.iconUrl}`}
+                  className="w-10 h-10 rounded-full object-cover border-2 border-white shadow-sm"
+                />
+                <div>
+                  <h3 className="font-black text-slate-800 text-lg">
+                    eSIM {state.area.areaName1}
+                  </h3>
+                  <p className="text-xs text-slate-500 font-bold uppercase tracking-wide mt-1">
+                    {/* {state.package.amountData} -  */}
+                    {state.package.dayDuration} days • x{state.quantity}
+                  </p>
+                </div>
+              </div>
+              <div className="text-right">
+                <p className="text-xs text-slate-400 line-through font-bold">
+                  ${state.checkoutDetails.paymentMoney}
+                </p>
+                <p className="text-[#0071e3] font-black text-xl">
+                  ${state.checkoutDetails.totalMoney}
+                </p>
+              </div>
+            </div>
+          </div>
+
+          {/* Payment Method */}
+          <div>
+            <h2 className="text-xl font-black text-[#003459] mb-6">
+              Payment Method
+            </h2>
+            <div className="space-y-3">
+              {loadPaymentChannel.map((method) => (
+                <label
+                  key={method.id}
+                  className={`flex items-center p-4 border rounded-2xl cursor-pointer transition-all ${
+                    paymentMethod === method.id
+                      ? "bg-blue-50 border-blue-200"
+                      : "border-slate-100 hover:bg-slate-50"
+                  }`}
+                >
+                  <input
+                    type="radio"
+                    name="payment"
+                    checked={paymentMethod === method.id}
+                    onChange={() => setPaymentMethod(method.id)}
+                    className="w-5 h-5 text-[#EE0434] border-slate-300 focus:ring-[#EE0434]"
+                  />
+                  <span className="ml-4 font-bold text-slate-700 flex items-center gap-3">
+                    <span className="text-xl w-6 text-center">
+                      {/* {method.imgUrl} */}
+                    </span>{" "}
+                    {method.name}
+                  </span>
+                </label>
+              ))}
+            </div>
+          </div>
+
+          {/* Terms */}
+          <div className="space-y-3 pt-2 px-1">
+            <label className="flex items-start cursor-pointer group">
+              <input
+                type="checkbox"
+                name="terms"
+                checked={agreements.terms}
+                onChange={handleCheckboxChange}
+                className="mt-1 w-4 h-4 rounded border-slate-300 text-[#0071e3] focus:ring-[#0071e3]"
+              />
+              <span className="ml-3 text-sm text-slate-500 font-medium group-hover:text-slate-700 transition-colors">
+                I have read and agree to the{" "}
+                <a
+                  href="#"
+                  className="text-[#0071e3] font-bold hover:underline"
+                >
+                  Terms of Service
+                </a>
+              </span>
+            </label>
+            <label className="flex items-start cursor-pointer group">
+              <input
+                type="checkbox"
+                className="mt-1 w-4 h-4 rounded border-slate-300 text-[#0071e3] focus:ring-[#0071e3]"
+                name="esim"
+                checked={agreements.esim}
+                onChange={handleCheckboxChange}
+              />
+              <span className="ml-3 text-sm text-slate-500 font-medium group-hover:text-slate-700 transition-colors">
+                I confirm that my device supports eSIM and has been network
+                unlocked
+              </span>
+            </label>
+            <label className="flex items-start cursor-pointer group">
+              <input
+                type="checkbox"
+                className="mt-1 w-4 h-4 rounded border-slate-300 text-[#0071e3] focus:ring-[#0071e3]"
+                name="vatInvoice"
+                checked={agreements.vatInvoice}
+                onChange={handleCheckboxChange}
+              />
+              <span className="ml-3 text-sm text-slate-500 font-medium group-hover:text-slate-700 transition-colors">
+                I want to issue a VAT Invoice
+              </span>
+            </label>
+          </div>
+
+          {/* Overview Card */}
+          <div className="bg-[#eef6f8] rounded-[32px] p-8 mt-8">
+            <h2 className="text-xl font-black text-[#003459] mb-6">Overview</h2>
+
+            <div className="relative mb-8">
+              <input
+                type="text"
+                placeholder="Promo code"
+                className="w-full bg-white border-2 border-transparent focus:border-[#0071e3]/20 rounded-2xl py-4 px-6 focus:outline-none transition-all text-slate-700 font-bold placeholder:text-slate-300"
+              />
+              <button className="absolute right-3 top-2 bottom-2 text-[#0071e3] font-black text-sm px-4 hover:bg-blue-50 rounded-xl transition-colors">
+                Apply
+              </button>
+            </div>
+
+            <div className="space-y-4 mb-8">
+              <div className="flex justify-between text-slate-600 font-bold">
+                <span>Subtotal:</span>
+                <span>
+                  $
+                  {(state.checkoutDetails.totalMoney * state.quantity).toFixed(
+                    1
+                  )}
+                </span>
+              </div>
+              <div className="flex justify-between text-green-600 font-bold">
+                <span>Discount:</span>
+                <span>-${(0 * state.quantity).toFixed(1)}</span>
+              </div>
+              <div className="flex justify-between items-center pt-4 border-t border-slate-200/50">
+                <span className="text-[#0071e3] font-black text-xl">
+                  Total ({state.quantity}):
+                </span>
+                <span className="text-[#0071e3] font-black text-3xl">
+                  $
+                  {(
+                    state.checkoutDetails.paymentMoney * state.quantity
+                  ).toFixed(1)}
+                </span>
+              </div>
+            </div>
+
+            <button
+              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 ${
+                form.firstName &&
+                form.lastName &&
+                form.email &&
+                agreements.terms &&
+                agreements.esim &&
+                agreements.vatInvoice &&
+                !loading.isSmallLoading
+                  ? "bg-[#EE0434] text-white hover:scale-[1.01] active:scale-[0.98]"
+                  : "bg-slate-100 text-slate-300 cursor-not-allowed"
+              }`}
+              disabled={
+                !form.firstName ||
+                !form.lastName ||
+                !form.email ||
+                !agreements.terms ||
+                !agreements.esim ||
+                !agreements.vatInvoice ||
+                loading.isSmallLoading
+              }
+              onClick={handleSubmit}
+            >
+              {loading.isSmallLoading && (
+                <div className="w-5 h-5 border-3 border-white/30 border-t-red-500 rounded-full animate-spin"></div>
+              )}
+              <span>Checkout</span>
+            </button>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+};
+
+export default CheckoutView;

+ 12 - 0
EsimLao/esim-vite/src/pages/home/HomeView.tsx

@@ -6,10 +6,22 @@ import HomeTestimonial from "./components/HomeTestimonial";
 import HomeProduct from "./components/HomeProduct";
 import HomeSearch from "./components/HomeSearch";
 import HomeFaq from "./components/HomeFaq";
+import { useAppDispatch } from "../../hooks/useRedux";
 
 const HomeView: React.FC = () => {
   const [simType, setSimType] = useState<"eSIM" | "Physical">("eSIM");
 
+  const navigate = useNavigate();
+  const dispatch = useAppDispatch();
+
+  useEffect(() => {
+    const params = new URLSearchParams(window.location.search);
+    const id = params.get("vpc_TxnResponseCode");
+    if (id) {
+      dis;
+    }
+  }, []);
+
   const steps = [
     {
       number: "1",

+ 9 - 4
EsimLao/esim-vite/src/pages/home/components/HomeFaq.tsx

@@ -82,7 +82,7 @@ const HomeFaq = () => {
                         : "text-slate-800 group-hover:text-[#EE0434]"
                     }`}
                   >
-                    faq.question
+                    {faq.question}
                   </span>
                   <div
                     className={`shrink-0 w-6 h-6 md:w-8 md:h-8 flex items-center justify-center transition-all duration-500 ${
@@ -114,9 +114,14 @@ const HomeFaq = () => {
                   }`}
                 >
                   <div className="overflow-hidden">
-                    <p className="text-slate-600 text-sm md:text-xl leading-relaxed font-medium pb-8">
-                      faq.answer
-                    </p>
+                    {/* show html */}
+                    <div
+                      dangerouslySetInnerHTML={{ __html: faq.answer }}
+                      className="text-slate-600 text-sm md:text-xl leading-relaxed font-medium pb-8"
+                    />
+                    {/* <p className="text-slate-600 text-sm md:text-xl leading-relaxed font-medium pb-8">
+                      {faq.answer}
+                    </p> */}
                   </div>
                 </div>
               </div>

+ 12 - 14
EsimLao/esim-vite/src/pages/home/components/HomeProduct.tsx

@@ -9,7 +9,8 @@ import {
   stopLoading,
 } from "../../../features/loading/loadingSlice";
 import { DataCacheKey, staleTime } from "../../../global/constants";
-import { AreaData } from "@/src/services/product/type";
+import { Area } from "../../../services/product/type";
+import { setAreas } from "../../../features/areas/areasSlice";
 
 const HomeProduct = () => {
   const [activeTab, setActiveTab] = useState<"country" | "region">("country");
@@ -19,16 +20,18 @@ const HomeProduct = () => {
 
   useEffect(() => {}, []);
 
-  const { data: loadAreaData = [] } = useQuery<AreaData[]>({
+  const { data: loadArea = [] } = useQuery<Area[]>({
     queryKey: [DataCacheKey.AREAS],
-    queryFn: async (): Promise<AreaData[]> => {
+    queryFn: async (): Promise<Area[]> => {
       try {
         dispatch(startLoading({}));
         const res = await productApi.loadArea({
           isCountry: "-1",
-          isPopular: "-1",
+          isPopular: "1",
         });
-        return res.data as AreaData[];
+        // save to redux store
+        dispatch(setAreas(res.data as Area[]));
+        return res.data as Area[];
       } catch (error) {
         console.error(error);
         return []; // 🔴 bắt buộc
@@ -39,15 +42,10 @@ const HomeProduct = () => {
     staleTime: staleTime,
   });
 
-  const handleProductClick = (p: AreaData) => {
-    navigate(`/product/${p.areaName1.toLowerCase()}`, {
+  const handleProductClick = (p: Area) => {
+    navigate(`/product/${p.id}`, {
       state: {
-        country: p.areaName1,
-        flag: p.iconUrl,
-        image:
-          p.areaName1 === "Thailand"
-            ? "https://images.unsplash.com/photo-1528181304800-2f140819898f?q=80&w=1000&auto=format&fit=crop"
-            : `https://images.unsplash.com/photo-1526772662000-3f88f10c053b?q=80&w=1000&auto=format&fit=crop`,
+        ...p,
       },
     });
     window.scrollTo({ top: 0, behavior: "smooth" });
@@ -87,7 +85,7 @@ const HomeProduct = () => {
 
       <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
         <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 md:gap-8">
-          {loadAreaData.map((p) => (
+          {loadArea.map((p) => (
             <div
               key={p.id}
               onClick={() => handleProductClick(p)}

+ 7 - 2
EsimLao/esim-vite/src/pages/news/NewsDetailView.tsx

@@ -12,12 +12,14 @@ const ArticleDetailView: React.FC = () => {
   const navigate = useNavigate();
   const dispatch = useAppDispatch();
   const article = location.state?.article as Article;
+  const [articleDetail, setArticleDetail] = useState<Article | null>(null);
 
   // load article detail
   const loadArticleDetailMutation = useMutation({
     mutationFn: async () => {
       dispatch(startLoading({}));
       const res = await articleApi.LoadArticleDetail({ articleId: article.id });
+      setArticleDetail(res.data.article);
       return res;
     },
     onSuccess: (data) => {
@@ -26,7 +28,7 @@ const ArticleDetailView: React.FC = () => {
       if (data && data.errorCode === "0") {
         console.log("Get article detail successful");
       } else {
-        console.error("Get article detail failed, no token received");
+        console.error("Get article detail failed:", data.message);
       }
     },
     onError: (error: any) => {
@@ -138,7 +140,10 @@ const ArticleDetailView: React.FC = () => {
             </h2>
             {/* <p className="text-base md:text-lg leading-relaxed mb-4 font-medium text-slate-600"> */}
             {/* show html  */}
-            <div dangerouslySetInnerHTML={{ __html: article.content }} />
+            <div
+              className="text-slate-600 text-sm md:text-xl leading-relaxed font-medium pb-8"
+              dangerouslySetInnerHTML={{ __html: articleDetail?.content || "" }}
+            />
             {/* </p> */}
           </article>
 

+ 269 - 71
EsimLao/esim-vite/src/pages/product-detail/ProductDetailView.tsx

@@ -1,18 +1,40 @@
-import React, { useState } from "react";
+import React, { useState, useMemo, useEffect } from "react";
 import { useLocation, useNavigate, Link } from "react-router-dom";
 import { SelectedProduct } from "../../services/types";
+import { Area, Package } from "../../services/product/type";
+import { useQuery } from "@tanstack/react-query";
+import { DataCacheKey, staleTime } from "../../global/constants";
+import { useAppDispatch, useAppSelector } from "../../hooks/useRedux";
+import { startLoading, stopLoading } from "../../features/loading/loadingSlice";
+import { productApi } from "../../apis/productApi";
+import { openPopup } from "../../features/popup/popupSlice";
+import { get } from "http";
 
 const ProductDetailView: React.FC = () => {
   const location = useLocation();
   const navigate = useNavigate();
-  const product = location.state as SelectedProduct;
-
-  const [selectedDays, setSelectedDays] = useState<number>(7);
+  const dispatch = useAppDispatch();
+  const area = location.state as Area;
+  const loading = useAppSelector((state) => state.loading);
+  const [selectedDays, setSelectedDays] = useState<number>(null);
   const [selectedData, setSelectedData] = useState<string>("Unlimited");
+  const [daysOptions, setDaysOptions] = useState<number[]>([]);
+  const [dataOptions, setDataOptions] = useState<string[]>([]);
+  const [daysActiveOptions, setDaysActiveOptions] = useState<number[]>([]);
+  const [dataActiveOptions, setDataActiveOptions] = useState<string[]>([]);
+  const [prices, setPrices] = useState<{
+    original: string;
+    final: string;
+    discountPercent: string;
+  }>({
+    original: "0.00",
+    final: "0.00",
+    discountPercent: "0",
+  });
   const [simType, setSimType] = useState<"eSIM" | "Physical">("eSIM");
   const [quantity, setQuantity] = useState<number>(1);
 
-  if (!product) {
+  if (!area) {
     return (
       <div className="min-h-screen flex items-center justify-center">
         <div className="text-center">
@@ -25,43 +47,173 @@ const ProductDetailView: React.FC = () => {
     );
   }
 
-  const daysOptions = [3, 5, 7, 8, 10, 15, 30];
-  const dataOptions = [
-    "1GB",
-    "2GB",
-    "3GB",
-    "5GB",
-    "10GB",
-    "15GB",
-    "20GB",
-    "50GB",
-    "Unlimited",
-  ];
-
-  const getPrices = () => {
-    const dataMultiplier: Record<string, number> = {
-      "1GB": 1,
-      "2GB": 1.5,
-      "3GB": 2,
-      "5GB": 3,
-      "10GB": 5,
-      "15GB": 7,
-      "20GB": 9,
-      "50GB": 15,
-      Unlimited: 20,
-    };
-    const base = selectedDays * 1.5 + (dataMultiplier[selectedData] || 5);
-    const discount = 0.1;
-    const original = base * quantity;
-    const final = original * (1 - discount);
+  const { data: loadPackage = [] } = useQuery<Package[]>({
+    queryKey: [DataCacheKey.PACKAGES],
+    queryFn: async (): Promise<Package[]> => {
+      try {
+        dispatch(startLoading({}));
+        const res = await productApi.loadPackage({
+          areaId: area.id,
+          isUnlimited: "-1",
+          isDaily: "-1",
+        });
+        return res.data as Package[];
+      } catch (error) {
+        console.error(error);
+        return []; // 🔴 bắt buộc
+      } finally {
+        dispatch(stopLoading());
+      }
+    },
+    staleTime: staleTime,
+  });
+
+  const options = useMemo(() => {
+    console.log("Calculating options from loadPackage");
+
+    const daysSet = new Set<number>();
+    const dataSet = new Set<string>();
+
+    loadPackage.forEach((p) => {
+      daysSet.add(p.dayDuration);
+      dataSet.add(p.amountData.toString());
+    });
+
+    const daysArray = Array.from(daysSet).sort((a, b) => a - b);
+
+    const dataArray = Array.from(dataSet).sort((a, b) => {
+      if (a === "Unlimited") return 1;
+      if (b === "Unlimited") return -1;
+      return parseInt(a) - parseInt(b);
+    });
+
     return {
-      original: original.toFixed(2),
-      final: final.toFixed(2),
-      discountPercent: (discount * 100).toFixed(0),
+      daysArray,
+      dataArray,
     };
+  }, [loadPackage]);
+
+  useEffect(() => {
+    setDaysOptions(options.daysArray);
+    setDataOptions(options.dataArray);
+    handleSelectDay(options.daysArray[0]);
+    handleSelectData(options.dataArray[0]);
+  }, [options]);
+
+  useEffect(() => {
+    getPrices();
+  }, [selectedDays, selectedData]);
+
+  const handleSelectDay = (day: number) => {
+    // filter data options based on selected day if needed
+    const dataSet = new Set<string>();
+    loadPackage.forEach((p) => {
+      if (p.dayDuration === day) {
+        dataSet.add(p.amountData.toString());
+      }
+    });
+    setSelectedDays(day);
+    setDataActiveOptions(Array.from(dataSet));
+  };
+
+  const handleSelectData = (data: string) => {
+    // filter day options based on selected data if needed
+    const daysSet = new Set<number>();
+    loadPackage.forEach((p) => {
+      if (p.amountData.toString() === data) {
+        daysSet.add(p.dayDuration);
+      }
+    });
+    setSelectedData(data);
+    setDaysActiveOptions(Array.from(daysSet));
+  };
+
+  const getPrices = (quantityParam?: number) => {
+    const quantityToUse =
+      quantityParam !== undefined ? quantityParam : quantity;
+    // find package based on selectedDays and selectedData
+    const selectedPackage = loadPackage.find(
+      (p) =>
+        p.dayDuration === selectedDays &&
+        p.amountData.toString() === selectedData
+    );
+    if (!selectedPackage) {
+      console.log(
+        "No package found for the selected options " +
+          selectedDays +
+          " days and " +
+          selectedData +
+          " data"
+      );
+      return {
+        original: "0.00",
+        final: "0.00",
+        discountPercent: "0",
+      };
+    }
+    console.log(
+      "Selected package: ",
+      selectedPackage +
+        " for prices calculation " +
+        selectedPackage.sellPrice +
+        " quantity " +
+        quantityToUse
+    );
+    setPrices({
+      original: quantityToUse * selectedPackage.displayPrice,
+      final: quantityToUse * selectedPackage.sellPrice,
+      discountPercent: "0",
+    });
   };
 
-  const prices = getPrices();
+  const handleQuantityChange = (change: number) => {
+    const newQuantity = Math.max(1, quantity + change);
+    setQuantity(newQuantity);
+    console.log("Selected quantity: ", newQuantity);
+    getPrices(newQuantity);
+  };
+
+  const handleBuyNow = async () => {
+    // Logic for custom order or standard buy
+    console.log("Buy now clicked");
+    const selectedPackage = loadPackage.find(
+      (p) =>
+        p.dayDuration === selectedDays &&
+        p.amountData.toString() === selectedData
+    );
+    if (!selectedPackage) {
+      alert("Please select a valid package");
+      return;
+    }
+    // call logic to proceed to checkout
+    const res = await productApi.checkout({
+      packgId: selectedPackage.id,
+      quantity: quantity,
+    });
+    if (res && res.errorCode === "0") {
+      console.log("Checkout details loaded:", res.data);
+      // navigate to checkout with selected options
+      navigate("/checkout", {
+        state: {
+          area: area,
+          package: selectedPackage,
+          quantity: quantity,
+          simType: simType,
+          checkoutDetails: res.data,
+        },
+      });
+    } else {
+      console.error("Failed to load checkout details:", res.message);
+      dispatch(
+        openPopup({
+          isSuccess: false,
+          title: "Checkout Error",
+          message: res.message || "Failed to proceed to checkout.",
+          buttonText: "Close",
+        })
+      );
+    }
+  };
 
   return (
     <div className="bg-white min-h-screen pb-20">
@@ -83,7 +235,7 @@ const ProductDetailView: React.FC = () => {
               strokeLinejoin="round"
             />
           </svg>
-          <span className="text-slate-900 font-bold">{product.country}</span>
+          <span className="text-slate-900 font-bold">{area.areaName1}</span>
         </nav>
       </div>
 
@@ -91,21 +243,21 @@ const ProductDetailView: React.FC = () => {
         <div className="lg:col-span-5 space-y-6">
           <div className="relative aspect-[3/4] rounded-[24px] md:rounded-[32px] overflow-hidden shadow-2xl group">
             <img
-              src={product.image}
-              alt={product.country}
+              src={area.imgUrl}
+              alt={area.areaName1}
               className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
             />
             <div className="absolute top-6 left-6 flex items-start space-x-4">
               <div className="w-12 h-12 md:w-16 md:h-16 rounded-full overflow-hidden border-2 border-white/50 shadow-lg">
                 <img
-                  src={`https://flagcdn.com/w160/${product.flag}.png`}
-                  alt={product.country}
+                  src={`${area.iconUrl}`}
+                  alt={area.areaName1}
                   className="w-full h-full object-cover scale-150"
                 />
               </div>
               <div className="text-white">
                 <h1 className="text-2xl md:text-4xl font-black drop-shadow-md">
-                  SIM {product.country}
+                  SIM {area.areaName1}
                 </h1>
                 <p className="text-sm md:text-lg font-bold text-white/90">
                   Verified: <span className="text-[#EE0434]">High Speed</span>
@@ -124,10 +276,14 @@ const ProductDetailView: React.FC = () => {
               {daysOptions.map((day) => (
                 <button
                   key={day}
-                  onClick={() => setSelectedDays(day)}
+                  onClick={() =>
+                    daysActiveOptions.includes(day) && handleSelectDay(day)
+                  }
                   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 ${
                     selectedDays === day
                       ? "border-[#EE0434] text-white bg-[#EE0434] shadow-md"
+                      : daysActiveOptions.includes(day)
+                      ? "border-[#ffffff] text-black bg-[#ffffff] shadow-md"
                       : "border-slate-100 text-slate-300"
                   }`}
                 >
@@ -145,41 +301,72 @@ const ProductDetailView: React.FC = () => {
               {dataOptions.map((data) => (
                 <button
                   key={data}
-                  onClick={() => setSelectedData(data)}
+                  onClick={() =>
+                    dataActiveOptions.includes(data) && handleSelectData(data)
+                  }
                   className={`h-10 md:h-14 rounded-xl md:rounded-2xl font-bold text-sm md:text-xl transition-all border-2 ${
                     selectedData === data
                       ? "border-[#EE0434] text-white bg-[#EE0434] shadow-md"
+                      : dataActiveOptions.includes(data)
+                      ? "border-[#ffffff] text-black bg-[#ffffff] shadow-md"
                       : "border-slate-100 text-slate-300"
                   }`}
                 >
-                  {data}
+                  {data === "0" ? "Unlimited" : data + " MB"}
                 </button>
               ))}
             </div>
           </div>
 
-          <div className="space-y-4">
-            <div className="flex p-1 bg-slate-50 rounded-2xl border border-slate-100">
-              <button
-                onClick={() => setSimType("eSIM")}
-                className={`flex-1 py-3 md:py-4 rounded-xl font-black text-sm md:text-2xl transition-all ${
-                  simType === "eSIM"
-                    ? "bg-[#EE0434] text-white shadow-lg"
-                    : "text-slate-300"
-                }`}
-              >
-                eSIM
-              </button>
-              <button
-                onClick={() => setSimType("Physical")}
-                className={`flex-1 py-3 md:py-4 rounded-xl font-black text-sm md:text-2xl transition-all ${
-                  simType === "Physical"
-                    ? "bg-[#EE0434] text-white shadow-lg"
-                    : "text-slate-300"
-                }`}
-              >
-                Physical SIM
-              </button>
+          <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+            <div className="space-y-4">
+              <h3 className="text-lg md:text-xl font-black text-slate-900 tracking-tight">
+                SIM Type
+              </h3>
+              <div className="flex p-1 bg-slate-50 rounded-2xl border border-slate-100">
+                <button
+                  onClick={() => setSimType("eSIM")}
+                  className={`flex-1 py-3 md:py-4 rounded-xl font-black text-sm md:text-2xl transition-all ${
+                    simType === "eSIM"
+                      ? "bg-[#EE0434] text-white shadow-lg"
+                      : "text-slate-300"
+                  }`}
+                >
+                  eSIM
+                </button>
+                <button
+                  onClick={() => setSimType("Physical")}
+                  className={`flex-1 py-3 md:py-4 rounded-xl font-black text-sm md:text-2xl transition-all ${
+                    simType === "Physical"
+                      ? "bg-[#EE0434] text-white shadow-lg"
+                      : "text-slate-300"
+                  }`}
+                >
+                  Physical SIM
+                </button>
+              </div>
+            </div>
+            <div className="space-y-4">
+              <h3 className="text-lg md:text-xl font-black text-slate-900 tracking-tight">
+                Quantity
+              </h3>
+              <div className="flex items-center space-x-4 p-2 bg-slate-50 rounded-2xl border border-slate-100 h-[68px] md:h-[76px]">
+                <button
+                  onClick={() => handleQuantityChange(-1)}
+                  className="w-12 h-full bg-white rounded-xl shadow-sm text-slate-600 font-bold text-2xl hover:bg-slate-100 transition-colors"
+                >
+                  -
+                </button>
+                <span className="flex-1 text-center font-black text-2xl text-slate-900">
+                  {quantity}
+                </span>
+                <button
+                  onClick={() => handleQuantityChange(1)}
+                  className="w-12 h-full bg-white rounded-xl shadow-sm text-slate-600 font-bold text-2xl hover:bg-slate-100 transition-colors"
+                >
+                  +
+                </button>
+              </div>
             </div>
           </div>
 
@@ -197,8 +384,19 @@ const ProductDetailView: React.FC = () => {
                 ${prices.final}
               </span>
             </div>
-            <button className="w-full bg-[#EE0434] text-white py-4 rounded-full font-black text-lg shadow-xl hover:scale-105 transition-all">
-              Buy now
+            <button
+              disabled={selectedData && selectedDays && loading.isSmallLoading}
+              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 ${
+                selectedData && selectedDays && !loading.isSmallLoading
+                  ? "bg-[#EE0434] text-white hover:scale-[1.01] active:scale-[0.98]"
+                  : "bg-slate-100 text-slate-300 cursor-not-allowed"
+              }`}
+              onClick={handleBuyNow}
+            >
+              {loading.isSmallLoading && (
+                <div className="w-5 h-5 border-3 border-white/30 border-t-red-500 rounded-full animate-spin"></div>
+              )}
+              <span>Buy now</span>
             </button>
           </div>
         </div>

+ 71 - 1
EsimLao/esim-vite/src/services/product/type.ts

@@ -1,4 +1,4 @@
-export interface AreaData {
+export interface Area {
   id: number;
   areaCode: string;
   areaName1: string;
@@ -13,3 +13,73 @@ export interface AreaData {
   promotionPercent: number;
   curency: string;
 }
+
+export interface Package {
+  id: number;
+  areaId: number;
+  packageCode: string;
+  displayPrice: number;
+  amountData: number;
+  dayDuration: number;
+  isUnlimited: number;
+  packageName: string;
+  title: string;
+  description: string;
+  moreInfo: string;
+  sellPrice: number;
+  apnType: number;
+  isKycVerify: number;
+  rechargeability: number;
+  isLocal: number;
+  status: number;
+  priority: number;
+  limitedPolicy: string;
+  isDaily: number;
+}
+
+export interface CheckoutDetailResponse {
+  createdDate: string;
+  totalMoney: number;
+  discount: number;
+  paymentMoney: number;
+  curency: string;
+  packageName: string;
+  discountPercent: number;
+}
+
+export interface PaymentChannel {
+  id: number;
+  name: string;
+  code: string;
+  description: string;
+  status: string;
+  role: string;
+  type: string;
+  priority: number;
+  fromVersion: string;
+  toVersion: string;
+  fromTime: string;
+  toTime: string;
+  actionType: string;
+  actionValue: string;
+  imgUrl: string;
+}
+
+export interface ConfirmOrderResponse {
+  id: number;
+  customerId: number;
+  createdDate: string | null;
+  totalMoney: number;
+  discount: number;
+  paymentMoney: number;
+  paymentId: number;
+  status: number;
+  paymentStatus: number;
+  transactionId: string;
+  curency: string;
+  orderId: number;
+  orderDetailId: number;
+  paymentUrl: string;
+  discountPercent: number;
+  customerInfo: any;
+}