student il y a 4 semaines
Parent
commit
0c83435b1f
32 fichiers modifiés avec 1542 ajouts et 129 suppressions
  1. 0 1
      Kitty_Fall/Kitty_Fall/Common/CommonUrl.cs
  2. 4 4
      Kitty_Fall/Kitty_Fall/Kitty_Fall.Apis/Business/User/UserBusinessImpl.cs
  3. 0 1
      Kitty_Fall/Kitty_Fall/Kitty_Fall.Apis/Filters/JwtAuthFilter.cs
  4. 8 17
      Kitty_Fall/Kitty_Fall/Kitty_Fall.Apis/appsettings.json
  5. 15 1
      Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/Controllers/HomeController.cs
  6. 228 0
      Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/Controllers/LoginController.cs
  7. 71 0
      Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/Helpers/LoginRequiredMiddleware.cs
  8. 92 0
      Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/Helpers/LoginSessionHelper.cs
  9. 1 86
      Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/Helpers/MyIdAutoLoginMiddleware.cs
  10. 3 2
      Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/Program.cs
  11. 23 5
      Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/Views/Home/Index.cshtml
  12. 3 3
      Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/Views/Leaderboard/Index.cshtml
  13. 181 0
      Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/Views/Login/Index.cshtml
  14. 3 3
      Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/Views/MonthlyWinners/Index.cshtml
  15. 66 0
      Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/Views/Shared/_Layout.cshtml
  16. 0 0
      Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/wwwroot/kitty/assets/kitty-fall/monthlymedal1.png
  17. 0 0
      Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/wwwroot/kitty/assets/kitty-fall/monthlymedal2.png
  18. 0 0
      Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/wwwroot/kitty/assets/kitty-fall/monthlymedal3.png
  19. 0 0
      Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/wwwroot/kitty/assets/kitty-fall/rankmedal1.png
  20. 0 0
      Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/wwwroot/kitty/assets/kitty-fall/rankmedal2.png
  21. 0 0
      Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/wwwroot/kitty/assets/kitty-fall/rankmedal3.png
  22. 310 0
      Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/wwwroot/kitty/css/all.min.css
  23. 307 0
      Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/wwwroot/kitty/css/input.css
  24. 3 3
      Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/wwwroot/kitty/js/all.min.js
  25. 3 3
      Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/wwwroot/kitty/js/input.js
  26. 32 0
      Kitty_Fall/Kitty_Fall/Kitty_Fall.Woker/Kitty_Fall.Woker.csproj
  27. 43 0
      Kitty_Fall/Kitty_Fall/Kitty_Fall.Woker/Program.cs
  28. 28 0
      Kitty_Fall/Kitty_Fall/Kitty_Fall.Woker/appsettings.Development.json
  29. 28 0
      Kitty_Fall/Kitty_Fall/Kitty_Fall.Woker/appsettings.json
  30. 30 0
      Kitty_Fall/Kitty_Fall/Kitty_Fall.Woker/log4net.config
  31. 6 0
      Kitty_Fall/Kitty_Fall/Kitty_Fall.sln
  32. 54 0
      database_usesAndSub.sql

+ 0 - 1
Kitty_Fall/Kitty_Fall/Common/CommonUrl.cs

@@ -2,7 +2,6 @@ public static class UrlConstant
 {
     public const String GetUserInfoUrl = "/apis/user/user-info";
     public const String GetUserTurnUrl = "/apis/user/get-turn";
-    public const String LoginUrl = "/apis/user/login";
     public const String UserRegisterUrl = "/apis/user/register";
     public const String UserRegisterSubOtp = "/apis/user/register-sub-otp";
     public const String UserRegisterSubConfirm = "/apis/user/register-sub-confirm";

+ 4 - 4
Kitty_Fall/Kitty_Fall/Kitty_Fall.Apis/Business/User/UserBusinessImpl.cs

@@ -717,8 +717,8 @@ namespace Kitty_Fall.Apis.Business.User
                     {
                         Id = (decimal)(await DbLogic.GenIdAsync(dbContext, "TRANSACTION_ACTIVE_SEQ") ?? 0),
                         Msisdn = msisdn,
-                        Otp = mOtp,
-                        //Otp = "111111",
+                        //Otp = mOtp,
+                        Otp = "111111",
                         TimeLeft = 5,
                         CreatedTime = DateTime.Now,
                         ValidTime = DateTime.Now.AddMinutes(5),
@@ -740,8 +740,8 @@ namespace Kitty_Fall.Apis.Business.User
                         );
                     }
 
-                    otpSender.Otp = mOtp;
-                    //otpSender.Otp = "111111";
+                    //otpSender.Otp = mOtp;
+                    otpSender.Otp = "111111";
                     otpSender.TransactionId = transCode;
                     otpSender.TimeLeft = otpSender.TimeLeft <= 0 ? 5 : otpSender.TimeLeft - 1;
                     otpSender.CreatedTime = DateTime.Now;

+ 0 - 1
Kitty_Fall/Kitty_Fall/Kitty_Fall.Apis/Filters/JwtAuthFilter.cs

@@ -31,7 +31,6 @@ namespace Kitty_Fall.Apis.Filters
         // Endpoint công khai — duyệt việc / đăng nhập / OTP / master-data: không cần token.
         private static readonly HashSet<string> PublicPaths = new(StringComparer.OrdinalIgnoreCase)
         {
-            UrlConstant.LoginUrl,
             UrlConstant.LoadPackUrl,
             UrlConstant.UserSendOtp,
             UrlConstant.UserResendOtp,

+ 8 - 17
Kitty_Fall/Kitty_Fall/Kitty_Fall.Apis/appsettings.json

@@ -1,22 +1,13 @@
 {
-  //"Connection": "Data Source=(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=127.0.0.1)(PORT=1521))(CONNECT_DATA=(SERVICE_NAME=ORA12C)));User Id=myjob;Password=HHwx4ISVN51cDam;Connection Timeout=120;",
-  "Connection": "Data Source=(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=127.0.0.1)(PORT=1539))(CONNECT_DATA=(SERVICE_NAME=ORA12C)));User Id=myjob;Password=123456;Connection Timeout=120;",
+  //"Connection": "Data Source=(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=127.0.0.1)(PORT=1521))(CONNECT_DATA=(SERVICE_NAME=ORA12C)));User Id=KittyFall;Password=HHwx4ISVN51cDam;Connection Timeout=120;",
+  "Connection": "Data Source=(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=127.0.0.1)(PORT=1539))(CONNECT_DATA=(SERVICE_NAME=ORA12C)));User Id=Kitty_Fall;Password=123456;Connection Timeout=120;",
   "OracleSessionTimeZone": "UTC",
   "CountryCode": "95",
-  "DefaultOtp": {
-    "Code": "111111",
-    "Msisdns": [
-      "95123456789",
-      "9587878787",
-      "9587200096",
-      "95123456999"
-    ]
-  },
   "MyIdVerifySubscription": false,
   "MyIdSubscriptionUrl": "https://apis.mytel.com.mm/myid-login/v1.0/api/individual/subscription?offset=0&limit=20",
-  "wsUrl": "http://127.0.0.1:8199/MyJobWs?wsdl",
-  "wsUser": "ws_myjob",
-  "wsPass": "Myjob@123A",
+  "wsUrl": "http://127.0.0.1:8199/KittyFallWs?wsdl",
+  "wsUser": "ws_KittyFall",
+  "wsPass": "KittyFall@123A",
   "Logging": {
     "LogLevel": {
       "Default": "Information",
@@ -31,9 +22,9 @@
     }
   },
   "Jwt": {
-    "Key": "MyJobApiJwtSecretKey-2026-CHANGE-THIS-IN-PRODUCTION-aB7xK9pQ2wL5nR8",
-    "Issuer": "MyJobApi",
-    "Audience": "MyJobClient"
+    "Key": "KittyFallApiJwtSecretKey-2026-CHANGE-THIS-IN-PRODUCTION-aB7xK9pQ2wL5nR8",
+    "Issuer": "KittyFallApi",
+    "Audience": "KittyFallClient"
   },
   "AllowedHosts": "*"
 }

+ 15 - 1
Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/Controllers/HomeController.cs

@@ -1,4 +1,5 @@
-using Kitty_Fall.Website.Models;
+using Common.Extension;
+using Kitty_Fall.Website.Models;
 using Microsoft.AspNetCore.Mvc;
 using System.Diagnostics;
 
@@ -23,6 +24,19 @@ namespace Kitty_Fall.Website.Controllers
             return View();
         }
 
+        public IActionResult ChangeLanguage(string lang)
+        {
+            lang = string.Equals(lang, "vi", StringComparison.OrdinalIgnoreCase) ? "vi" : "en";
+
+            Response.Cookies.Append(
+                Microsoft.AspNetCore.Localization.CookieRequestCultureProvider.DefaultCookieName,
+                Microsoft.AspNetCore.Localization.CookieRequestCultureProvider.MakeCookieValue(new Microsoft.AspNetCore.Localization.RequestCulture(lang)),
+                new CookieOptions { Expires = DateTimeOffset.UtcNow.AddYears(1) }
+            );
+            HttpContext.Session.SetComplexData("culture", lang);
+            return Json(new { success = true });
+        }
+
         [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
         public IActionResult Error()
         {

+ 228 - 0
Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/Controllers/LoginController.cs

@@ -0,0 +1,228 @@
+using Common;
+using Common.Extension;
+using Common.Http.Apis.Request;
+using Kitty_Fall.Website.Helpers;
+using Microsoft.AspNetCore.Mvc;
+using Newtonsoft.Json.Linq;
+
+namespace Kitty_Fall.Website.Controllers
+{
+    public class LoginController : Controller
+    {
+        private const string TestServiceName = "Kitty_Fall";
+        private const string PendingOtpMsisdnKey = "testLoginOtpMsisdn";
+        private const string PendingOtpTransactionKey = "testLoginOtpTransactionId";
+        private readonly IConfiguration _configuration;
+        private static readonly log4net.ILog log = log4net.LogManager.GetLogger(typeof(LoginController));
+
+        public LoginController(IConfiguration configuration)
+        {
+            _configuration = configuration;
+        }
+
+        [HttpGet]
+        public IActionResult Index(string? returnUrl = null)
+        {
+            var currentMsisdn = HttpContext.Session.GetComplexData<string>("msisdn");
+            if (!string.IsNullOrWhiteSpace(currentMsisdn))
+            {
+                return RedirectToAction("Index", "Home");
+            }
+
+            ViewBag.ReturnUrl = NormalizeReturnUrl(returnUrl);
+            ViewBag.CurrentMsisdn = currentMsisdn;
+            ViewBag.PendingOtpMsisdn = HttpContext.Session.GetComplexData<string>(PendingOtpMsisdnKey);
+            ViewBag.PendingOtpTransactionId = HttpContext.Session.GetComplexData<string>(PendingOtpTransactionKey);
+            return View();
+        }
+
+        [HttpPost]
+        public async Task<IActionResult> SendOtpAction(string msisdn)
+        {
+            if (string.IsNullOrWhiteSpace(msisdn))
+            {
+                return Json(new { errorCode = -1, message = "Please enter MSISDN." });
+            }
+
+            var baseUrl = GetParameter("url");
+            if (string.IsNullOrWhiteSpace(baseUrl))
+            {
+                return Json(new { errorCode = -1, message = "Missing API base URL." });
+            }
+
+            try
+            {
+                var language = GetCurrentLanguage();
+                var res = await DotnetLib.Rest.RestHandler.SendPostAsync(
+                    log,
+                    baseUrl + UrlConstant.UserSendOtp,
+                    new UserSendOtpReq
+                    {
+                        msisdn = msisdn.Trim(),
+                        serviceName = TestServiceName,
+                        language = language
+                    },
+                    language
+                );
+
+                if (res?.data == null)
+                {
+                    return Json(new { errorCode = -1, message = "Send OTP API returned empty response." });
+                }
+
+                var jObject = JObject.Parse(res.data);
+                var errorCode = ReadErrorCode(jObject);
+                var message = jObject["message"]?.ToString() ?? jObject["msg"]?.ToString() ?? "";
+                if (errorCode != CommonErrorCode.SUCCESS)
+                {
+                    return Json(new { errorCode, message = string.IsNullOrWhiteSpace(message) ? "Send OTP failed." : message });
+                }
+
+                var data = jObject["data"];
+                var normalizedMsisdn = data?["msisdn"]?.ToString() ?? msisdn.Trim();
+                var transactionId = data?["transactionId"]?.ToString();
+                if (string.IsNullOrWhiteSpace(transactionId))
+                {
+                    return Json(new { errorCode = -1, message = "Send OTP succeeded but transactionId is empty." });
+                }
+
+                SessionExtension.SetComplexData(HttpContext.Session, PendingOtpMsisdnKey, normalizedMsisdn.ToString());
+                SessionExtension.SetComplexData(HttpContext.Session, PendingOtpTransactionKey, transactionId);
+
+                return Json(new
+                {
+                    errorCode,
+                    message = string.IsNullOrWhiteSpace(message) ? "Enter OTP from message." : message,
+                    msisdn = normalizedMsisdn,
+                    transactionId
+                });
+            }
+            catch (Exception ex)
+            {
+                log.Error("Test send OTP error: ", ex);
+                return Json(new { errorCode = -1, message = "Send OTP failed. Please check API service." });
+            }
+        }
+
+        [HttpPost]
+        public async Task<IActionResult> ConfirmOtpAction(string msisdn, string transactionId, string otp, string? returnUrl = null)
+        {
+            if (string.IsNullOrWhiteSpace(msisdn)
+                || string.IsNullOrWhiteSpace(transactionId)
+                || string.IsNullOrWhiteSpace(otp))
+            {
+                return Json(new { errorCode = -1, message = "Please enter OTP." });
+            }
+
+            var baseUrl = GetParameter("url");
+            if (string.IsNullOrWhiteSpace(baseUrl))
+            {
+                return Json(new { errorCode = -1, message = "Missing API base URL." });
+            }
+
+            try
+            {
+                var language = GetCurrentLanguage();
+                var res = await DotnetLib.Rest.RestHandler.SendPostAsync(
+                    log,
+                    baseUrl + UrlConstant.UserOtpConfirm,
+                    new UserSendOtpReq
+                    {
+                        msisdn = msisdn.Trim(),
+                        serviceName = TestServiceName,
+                        language = language,
+                        transactionId = transactionId.Trim(),
+                        otp = otp.Trim()
+                    },
+                    language
+                );
+
+                if (res?.data == null)
+                {
+                    return Json(new { errorCode = -1, message = "Confirm OTP API returned empty response." });
+                }
+
+                var jObject = JObject.Parse(res.data);
+                var errorCode = ReadErrorCode(jObject);
+                var message = jObject["message"]?.ToString() ?? jObject["msg"]?.ToString() ?? "";
+                if (errorCode != CommonErrorCode.SUCCESS)
+                {
+                    return Json(new { errorCode, message = string.IsNullOrWhiteSpace(message) ? "Confirm OTP failed." : message });
+                }
+
+                var loggedMsisdn = jObject["data"]?["accountUser"]?["msisdn"]?.ToString()
+                    ?? jObject["data"]?["accountUser"]?["Msisdn"]?.ToString()
+                    ?? msisdn.Trim();
+
+                LoginSessionHelper.SaveLoginSession(HttpContext, loggedMsisdn, jObject);
+                ClearPendingOtp();
+                await PackagesCacheHelper.LoadAndCacheAsync(log, HttpContext.Session, baseUrl, language);
+
+                return Json(new
+                {
+                    errorCode,
+                    message = string.IsNullOrWhiteSpace(message) ? "Success" : message,
+                    redirectUrl = Url.Action("Index", "Home") ?? "/"
+                });
+            }
+            catch (Exception ex)
+            {
+                log.Error("Test confirm OTP error: ", ex);
+                return Json(new { errorCode = -1, message = "Confirm OTP failed. Please check API service and OTP." });
+            }
+        }
+
+        [HttpPost]
+        [ValidateAntiForgeryToken]
+        public IActionResult Logout()
+        {
+            LoginSessionHelper.ClearLoginSession(HttpContext);
+            ClearPendingOtp();
+            return RedirectToAction("Index", "Login");
+        }
+
+        [HttpPost]
+        public IActionResult LogoutBackAction()
+        {
+            LoginSessionHelper.ClearLoginSession(HttpContext);
+            ClearPendingOtp();
+            return Json(new { errorCode = CommonErrorCode.SUCCESS, redirectUrl = "mytel://back" });
+        }
+
+        private void ClearPendingOtp()
+        {
+            HttpContext.Session.Remove(PendingOtpMsisdnKey);
+            HttpContext.Session.Remove(PendingOtpTransactionKey);
+        }
+
+        private static int ReadErrorCode(JObject jObject)
+        {
+            var value = jObject["errorCode"]?.ToString();
+            return int.TryParse(value, out var errorCode) ? errorCode : -1;
+        }
+
+        private string GetParameter(string key)
+        {
+            return _configuration.GetSection(key).Value
+                ?? _configuration.GetSection("Url").Value
+                ?? "";
+        }
+
+        private string GetCurrentLanguage()
+        {
+            var sessionLang = HttpContext.Session.GetComplexData<string>("culture");
+            if (!string.IsNullOrWhiteSpace(sessionLang)) return sessionLang;
+            return System.Globalization.CultureInfo.CurrentUICulture.Name.StartsWith("vi", StringComparison.OrdinalIgnoreCase) ? "vi" : "en";
+        }
+
+        private string NormalizeReturnUrl(string? returnUrl)
+        {
+            if (!string.IsNullOrWhiteSpace(returnUrl) && Url.IsLocalUrl(returnUrl))
+            {
+                return returnUrl;
+            }
+
+            return Url.Action("Index", "Home") ?? "/";
+        }
+    }
+}

+ 71 - 0
Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/Helpers/LoginRequiredMiddleware.cs

@@ -0,0 +1,71 @@
+using Common.Extension;
+
+namespace Kitty_Fall.Website.Helpers;
+
+public class LoginRequiredMiddleware
+{
+    private readonly RequestDelegate _next;
+
+    public LoginRequiredMiddleware(RequestDelegate next)
+    {
+        _next = next;
+    }
+
+    public async Task InvokeAsync(HttpContext context)
+    {
+        if (ShouldSkip(context))
+        {
+            await _next(context);
+            return;
+        }
+
+        var msisdn = context.Session.GetComplexData<string>("msisdn");
+        if (!string.IsNullOrWhiteSpace(msisdn))
+        {
+            await _next(context);
+            return;
+        }
+
+        if (IsAjaxRequest(context.Request))
+        {
+            context.Response.StatusCode = StatusCodes.Status401Unauthorized;
+            await context.Response.WriteAsJsonAsync(new
+            {
+                success = false,
+                errorCode = Common.CommonErrorCode.LoginRequired,
+                redirectUrl = BuildLoginUrl(context)
+            });
+            return;
+        }
+
+        context.Response.Redirect(BuildLoginUrl(context));
+    }
+
+    private static bool ShouldSkip(HttpContext context)
+    {
+        var path = context.Request.Path;
+
+        if (path.StartsWithSegments("/Login")) return true;
+        if (path.StartsWithSegments("/Home/ChangeLanguage")) return true;
+        if (path.StartsWithSegments("/favicon.ico")) return true;
+        if (path.StartsWithSegments("/kitty")) return true;
+        if (path.StartsWithSegments("/css")) return true;
+        if (path.StartsWithSegments("/js")) return true;
+        if (path.StartsWithSegments("/lib")) return true;
+        if (path.StartsWithSegments("/assets")) return true;
+
+        return false;
+    }
+
+    private static bool IsAjaxRequest(HttpRequest request)
+    {
+        return string.Equals(request.Headers["X-Requested-With"], "XMLHttpRequest", StringComparison.OrdinalIgnoreCase)
+            || request.Headers.Accept.Any(x => x?.Contains("application/json", StringComparison.OrdinalIgnoreCase) == true);
+    }
+
+    private static string BuildLoginUrl(HttpContext context)
+    {
+        var returnUrl = $"{context.Request.PathBase}{context.Request.Path}{context.Request.QueryString}";
+        return "/Login?returnUrl=" + Uri.EscapeDataString(string.IsNullOrWhiteSpace(returnUrl) ? "/" : returnUrl);
+    }
+}

+ 92 - 0
Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/Helpers/LoginSessionHelper.cs

@@ -0,0 +1,92 @@
+using Common.Extension;
+using Newtonsoft.Json.Linq;
+
+namespace Kitty_Fall.Website.Helpers;
+
+public static class LoginSessionHelper
+{
+    public static void SaveLoginSession(HttpContext context, string msisdn, JObject responseJson)
+    {
+        var data = responseJson["data"];
+        ClearLoginSession(context);
+        context.Session.SetComplexData("msisdn", msisdn);
+
+        var apiToken = data?["token"]?.ToString();
+        if (!string.IsNullOrWhiteSpace(apiToken))
+        {
+            context.Session.SetString("apiToken", apiToken);
+        }
+
+        var accountUser = data?["accountUser"];
+        var role = "USER";
+        if (accountUser != null)
+        {
+            role = accountUser["role"]?.ToString() ?? accountUser["Role"]?.ToString() ?? "USER";
+            context.Session.SetComplexData("userId", accountUser["id"] ?? accountUser["Id"] ?? "");
+            context.Session.SetComplexData("role", role);
+            context.Session.SetComplexData("profilePicture", accountUser["picture"]?.ToString() ?? accountUser["Picture"]?.ToString() ?? "");
+        }
+
+        var regInfos = data?["regInfos"] ?? data?["RegInfos"];
+        if (string.Equals(role, "EMPLOYER", StringComparison.OrdinalIgnoreCase))
+        {
+            context.Session.Remove("regInfos");
+            context.Session.Remove("hasAnyRegInfo");
+            context.Session.Remove("canUseSubscriptionFeatures");
+            context.Session.Remove("hasPaidToday");
+        }
+        else
+        {
+            if (regInfos != null)
+            {
+                context.Session.SetComplexData("regInfos", regInfos);
+            }
+            else
+            {
+                context.Session.Remove("regInfos");
+            }
+
+            SetSubscriptionAccessSession(context, data);
+            SetHasAnyRegInfoSession(context, data);
+        }
+    }
+
+    public static void ClearLoginSession(HttpContext context)
+    {
+        context.Session.Remove("msisdn");
+        context.Session.Remove("apiToken");
+        context.Session.Remove("userId");
+        context.Session.Remove("role");
+        context.Session.Remove("profilePicture");
+        context.Session.Remove("regInfos");
+        context.Session.Remove("hasAnyRegInfo");
+        context.Session.Remove("canUseSubscriptionFeatures");
+        context.Session.Remove("hasPaidToday");
+    }
+
+    private static void SetHasAnyRegInfoSession(HttpContext context, JToken? data)
+    {
+        var hasAnyRegInfo = data?["hasAnyRegInfo"] ?? data?["HasAnyRegInfo"];
+        var regInfos = data?["regInfos"] ?? data?["RegInfos"];
+        var value = ReadBool(hasAnyRegInfo) || (regInfos is JArray arr && arr.Count > 0);
+        context.Session.SetString("hasAnyRegInfo", value.ToString());
+    }
+
+    private static void SetSubscriptionAccessSession(HttpContext context, JToken? data)
+    {
+        var canUse = data?["canUseSubscriptionFeatures"] ?? data?["CanUseSubscriptionFeatures"];
+        var hasPaid = data?["hasPaidToday"] ?? data?["HasPaidToday"];
+        var value = ReadBool(canUse) || ReadBool(hasPaid);
+        context.Session.SetString("canUseSubscriptionFeatures", value.ToString());
+        context.Session.SetString("hasPaidToday", value.ToString());
+    }
+
+    private static bool ReadBool(JToken? token)
+    {
+        if (token == null) return false;
+        var value = token.ToString();
+        if (bool.TryParse(value, out var boolValue)) return boolValue;
+        if (decimal.TryParse(value, out var numberValue)) return numberValue != 0;
+        return false;
+    }
+}

+ 1 - 86
Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/Helpers/MyIdAutoLoginMiddleware.cs

@@ -133,7 +133,7 @@ public class MyIdAutoLoginMiddleware
             return;
         }
 
-        SaveLoginSession(context, msisdn, responseJson);
+        LoginSessionHelper.SaveLoginSession(context, msisdn, responseJson);
         if (shouldTrace)
         {
             var data = responseJson["data"];
@@ -144,78 +144,6 @@ public class MyIdAutoLoginMiddleware
         }
     }
 
-    private static void SaveLoginSession(HttpContext context, string msisdn, JObject responseJson)
-    {
-        var data = responseJson["data"];
-        ClearLoginSession(context);
-        context.Session.SetComplexData("msisdn", msisdn);
-
-        var apiToken = data?["token"]?.ToString();
-        if (!string.IsNullOrWhiteSpace(apiToken))
-        {
-            context.Session.SetString("apiToken", apiToken);
-        }
-
-        var accountUser = data?["accountUser"];
-        var role = "USER";
-        if (accountUser != null)
-        {
-            role = accountUser["role"]?.ToString() ?? accountUser["Role"]?.ToString() ?? "USER";
-            context.Session.SetComplexData("userId", accountUser["id"] ?? accountUser["Id"] ?? "");
-            context.Session.SetComplexData("role", role);
-            context.Session.SetComplexData("profilePicture", accountUser["picture"]?.ToString() ?? accountUser["Picture"]?.ToString() ?? "");
-        }
-
-        var regInfos = data?["regInfos"] ?? data?["RegInfos"];
-        if (string.Equals(role, "EMPLOYER", StringComparison.OrdinalIgnoreCase))
-        {
-            context.Session.Remove("regInfos");
-            context.Session.Remove("hasAnyRegInfo");
-            context.Session.Remove("canUseSubscriptionFeatures");
-            context.Session.Remove("hasPaidToday");
-        }
-        else
-        {
-            if (regInfos != null)
-            {
-                context.Session.SetComplexData("regInfos", regInfos);
-            }
-            else
-            {
-                context.Session.Remove("regInfos");
-            }
-
-            SetSubscriptionAccessSession(context, data);
-            SetHasAnyRegInfoSession(context, data);
-        }
-    }
-
-    private static void SetHasAnyRegInfoSession(HttpContext context, JToken? data)
-    {
-        var hasAnyRegInfo = data?["hasAnyRegInfo"] ?? data?["HasAnyRegInfo"];
-        var regInfos = data?["regInfos"] ?? data?["RegInfos"];
-        var value = ReadBool(hasAnyRegInfo) || (regInfos is JArray arr && arr.Count > 0);
-        context.Session.SetString("hasAnyRegInfo", value.ToString());
-    }
-
-    private static void SetSubscriptionAccessSession(HttpContext context, JToken? data)
-    {
-        var canUse = data?["canUseSubscriptionFeatures"] ?? data?["CanUseSubscriptionFeatures"];
-        var hasPaid = data?["hasPaidToday"] ?? data?["HasPaidToday"];
-        var value = ReadBool(canUse) || ReadBool(hasPaid);
-        context.Session.SetString("canUseSubscriptionFeatures", value.ToString());
-        context.Session.SetString("hasPaidToday", value.ToString());
-    }
-
-    private static bool ReadBool(JToken? token)
-    {
-        if (token == null) return false;
-        var value = token.ToString();
-        if (bool.TryParse(value, out var boolValue)) return boolValue;
-        if (decimal.TryParse(value, out var numberValue)) return numberValue != 0;
-        return false;
-    }
-
     private string GetParameter(string key)
     {
         return _configuration.GetSection(key).Value ?? "";
@@ -232,19 +160,6 @@ public class MyIdAutoLoginMiddleware
         return CommonLogic.ValidateMsisdn(GetParameter("CountryCode"), rawMsisdn);
     }
 
-    private static void ClearLoginSession(HttpContext context)
-    {
-        context.Session.Remove("msisdn");
-        context.Session.Remove("apiToken");
-        context.Session.Remove("userId");
-        context.Session.Remove("role");
-        context.Session.Remove("profilePicture");
-        context.Session.Remove("regInfos");
-        context.Session.Remove("hasAnyRegInfo");
-        context.Session.Remove("canUseSubscriptionFeatures");
-        context.Session.Remove("hasPaidToday");
-    }
-
     private static string? ReadHeader(HttpRequest request, params string[] names)
     {
         foreach (var name in names)

+ 3 - 2
Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/Program.cs

@@ -26,8 +26,8 @@ builder.Services.AddSession(options =>
 
 builder.Services.Configure<RequestLocalizationOptions>(options =>
 {
-    var cultures = new List<CultureInfo> { new CultureInfo("mm"), new CultureInfo("en") };
-    options.DefaultRequestCulture = new Microsoft.AspNetCore.Localization.RequestCulture("mm");
+    var cultures = new List<CultureInfo> { new CultureInfo("vi"), new CultureInfo("en") };
+    options.DefaultRequestCulture = new Microsoft.AspNetCore.Localization.RequestCulture("vi");
     options.SupportedCultures = cultures;
     options.SupportedUICultures = cultures;
 });
@@ -47,6 +47,7 @@ app.UseStaticFiles();
 
 app.UseSession();
 app.UseMiddleware<Kitty_Fall.Website.Helpers.MyIdAutoLoginMiddleware>();
+app.UseMiddleware<Kitty_Fall.Website.Helpers.LoginRequiredMiddleware>();
 
 app.UseRouting();
 

+ 23 - 5
Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/Views/Home/Index.cshtml

@@ -14,11 +14,16 @@
                 <span>0912 345 678</span>
             </div>
         </div>
-        <button class="menu-button" aria-label="Open menu">
-            <span></span>
-            <span></span>
-            <span></span>
-        </button>
+        <div class="topbar-actions">
+            <button class="home-back-button" type="button" aria-label="Back" onclick="logoutAndBack()">
+                <span></span>
+            </button>
+            <button class="menu-button" type="button" aria-label="Open menu">
+                <span></span>
+                <span></span>
+                <span></span>
+            </button>
+        </div>
     </header>
 
     <section class="stats-shell home-screen scale-floor" data-scale-floor="368" data-scale-height="80" data-scale-keep-height="true" aria-label="Player status">
@@ -199,4 +204,17 @@
     </div>
 </main>
 
+@section Scripts {
+    <script>
+        function logoutAndBack() {
+            $.ajax({
+                url: "/Login/LogoutBackAction",
+                type: "POST",
+                complete: function () {
+                    window.location.href = "mytel://back";
+                }
+            });
+        }
+    </script>
+}
 

+ 3 - 3
Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/Views/Leaderboard/Index.cshtml

@@ -34,17 +34,17 @@
 
                 <div class="leaderboard-rows">
                     <div class="leaderboard-row" data-rank-row="1">
-                        <span class="leaderboard-rank"><img src="~/kitty/assets/kitty-fall/rank-medal-1.png" alt="1"></span>
+                        <span class="leaderboard-rank"><img src="~/kitty/assets/kitty-fall/rankmedal1.png" alt="1"></span>
                         <span class="leaderboard-phone">08** *** 780</span>
                         <strong><img src="~/kitty/assets/kitty-fall/leaderboard-score-icon.png" alt=""><b>58,750</b></strong>
                     </div>
                     <div class="leaderboard-row" data-rank-row="2">
-                        <span class="leaderboard-rank"><img src="~/kitty/assets/kitty-fall/rank-medal-2.png" alt="2"></span>
+                        <span class="leaderboard-rank"><img src="~/kitty/assets/kitty-fall/rankmedal2.png" alt="2"></span>
                         <span class="leaderboard-phone">08** *** 780</span>
                         <strong><img src="~/kitty/assets/kitty-fall/leaderboard-score-icon.png" alt=""><b>50,250</b></strong>
                     </div>
                     <div class="leaderboard-row" data-rank-row="3">
-                        <span class="leaderboard-rank"><img src="~/kitty/assets/kitty-fall/rank-medal-3.png" alt="3"></span>
+                        <span class="leaderboard-rank"><img src="~/kitty/assets/kitty-fall/rankmedal3.png" alt="3"></span>
                         <span class="leaderboard-phone">08** *** 780</span>
                         <strong><img src="~/kitty/assets/kitty-fall/leaderboard-score-icon.png" alt=""><b>45,050</b></strong>
                     </div>

+ 181 - 0
Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/Views/Login/Index.cshtml

@@ -0,0 +1,181 @@
+@{
+    ViewData["Title"] = "Test Login";
+    var returnUrl = ViewBag.ReturnUrl as string ?? "/";
+    var currentMsisdn = ViewBag.CurrentMsisdn as string;
+    var pendingOtpMsisdn = ViewBag.PendingOtpMsisdn as string;
+    var pendingOtpTransactionId = ViewBag.PendingOtpTransactionId as string;
+    var isOtpPending = !string.IsNullOrWhiteSpace(pendingOtpTransactionId);
+}
+
+<section class="kitty-app login-screen" aria-label="Test login">
+    <img class="kitty-bg" src="~/kitty/assets/kitty-fall/bg.png" alt="">
+    <div class="kitty-bg-fade"></div>
+
+    <div class="login-panel">
+        <img class="login-logo" src="~/kitty/assets/kitty-fall/logo.png" alt="Kitty Fall">
+        <img class="login-cat" src="~/kitty/assets/kitty-fall/cat.png" alt="">
+
+        <div class="login-card" data-login-card>
+            <div class="login-card-head">
+                <strong>Login</strong>
+            </div>
+
+            @if (!string.IsNullOrWhiteSpace(currentMsisdn))
+            {
+                <div class="login-session">
+                    <span>Logged in</span>
+                    <strong>@currentMsisdn</strong>
+                    <form asp-controller="Login" asp-action="Logout" method="post">
+                        @Html.AntiForgeryToken()
+                        <button type="submit">Logout</button>
+                    </form>
+                </div>
+            }
+
+            <div class="login-alert login-alert-info @(isOtpPending ? "" : "login-hidden")" data-login-message>
+                @(isOtpPending ? "Enter OTP from message." : "")
+            </div>
+
+            <div class="login-form">
+                <input type="hidden" data-return-url value="@returnUrl">
+                <input type="hidden" data-transaction-id value="@pendingOtpTransactionId">
+
+                <label class="login-field">
+                    <span>MSISDN</span>
+                    <input data-msisdn type="text" autocomplete="tel" placeholder="959xxxxxxxx" value="@pendingOtpMsisdn" readonly="@isOtpPending">
+                </label>
+
+                <label class="login-field @(isOtpPending ? "" : "login-hidden")" data-otp-field>
+                    <span>OTP</span>
+                    <input data-otp type="text" inputmode="numeric" autocomplete="one-time-code" placeholder="Enter OTP">
+                </label>
+
+                <button class="login-submit @(isOtpPending ? "login-hidden" : "")" type="button" data-send-otp onclick="sendOtp()">
+                    <img src="~/kitty/assets/kitty-fall/play-icon.svg" alt="">
+                    <span>Send OTP</span>
+                </button>
+
+                <button class="login-submit @(isOtpPending ? "" : "login-hidden")" type="button" data-confirm-otp onclick="confirmOtp()">
+                    <img src="~/kitty/assets/kitty-fall/play-icon.svg" alt="">
+                    <span>Confirm OTP</span>
+                </button>
+            </div>
+        </div>
+    </div>
+</section>
+
+@section Scripts {
+    <script>
+        function getLoginElements() {
+            const card = document.querySelector("[data-login-card]");
+            return {
+                card: card,
+                message: card?.querySelector("[data-login-message]"),
+                msisdnInput: card?.querySelector("[data-msisdn]"),
+                otpInput: card?.querySelector("[data-otp]"),
+                otpField: card?.querySelector("[data-otp-field]"),
+                transactionInput: card?.querySelector("[data-transaction-id]"),
+                returnUrlInput: card?.querySelector("[data-return-url]"),
+                sendButton: card?.querySelector("[data-send-otp]"),
+                confirmButton: card?.querySelector("[data-confirm-otp]")
+            };
+        }
+
+        function showLoginMessage(text, isInfo) {
+            const el = getLoginElements();
+            if (!el.message) return;
+            el.message.textContent = text || "";
+            el.message.classList.toggle("login-hidden", !text);
+            el.message.classList.toggle("login-alert-info", !!isInfo);
+        }
+
+        function setLoginLoading(button, isLoading, text) {
+            button.disabled = isLoading;
+            button.querySelector("span").textContent = text;
+        }
+
+        function showOtpStep(res) {
+            const el = getLoginElements();
+            el.transactionInput.value = res.transactionId || "";
+            el.msisdnInput.value = res.msisdn || el.msisdnInput.value.trim();
+            el.msisdnInput.readOnly = true;
+            el.otpField.classList.remove("login-hidden");
+            el.sendButton.classList.add("login-hidden");
+            el.confirmButton.classList.remove("login-hidden");
+            showLoginMessage(res.message || "Enter OTP from message.", true);
+            el.otpInput.focus();
+        }
+
+        function sendOtp() {
+            const el = getLoginElements();
+            if (!el.card || !window.jQuery) return;
+
+            const msisdn = el.msisdnInput.value.trim();
+            showLoginMessage("", false);
+            setLoginLoading(el.sendButton, true, "Sending...");
+
+            $.ajax({
+                url: "/Login/SendOtpAction",
+                type: "POST",
+                data: { msisdn: msisdn },
+                success: function (res) {
+                    console.log("GetOtp Response:", res);
+                    setLoginLoading(el.sendButton, false, "Send OTP");
+
+                    if (res && (res.errorCode === 0 || res.errorCode === 200)) {
+                        showOtpStep(res);
+                        return;
+                    }
+
+                    showLoginMessage((res && res.message) || "Send OTP failed.", false);
+                },
+                error: function (err) {
+                    console.error("GetOtp Error:", err);
+                    setLoginLoading(el.sendButton, false, "Send OTP");
+                    showLoginMessage("Send OTP failed. Please check API service.", false);
+                }
+            });
+        }
+
+        function confirmOtp() {
+            const el = getLoginElements();
+            if (!el.card || !window.jQuery) return;
+
+            showLoginMessage("", false);
+            if (!el.otpInput.value.trim()) {
+                showLoginMessage("Please enter OTP.", false);
+                el.otpInput.focus();
+                return;
+            }
+
+            setLoginLoading(el.confirmButton, true, "Checking...");
+
+            $.ajax({
+                url: "/Login/ConfirmOtpAction",
+                type: "POST",
+                data: {
+                    msisdn: el.msisdnInput.value.trim(),
+                    transactionId: el.transactionInput.value,
+                    otp: el.otpInput.value.trim(),
+                    returnUrl: el.returnUrlInput.value
+                },
+                success: function (res) {
+                    console.log("ConfirmOtp Response:", res);
+                    setLoginLoading(el.confirmButton, false, "Confirm OTP");
+
+                    if (res && (res.errorCode === 0 || res.errorCode === 200)) {
+                        window.location.href = res.redirectUrl || "/";
+                        return;
+                    }
+
+                    showLoginMessage((res && res.message) || "Confirm OTP failed.", false);
+                },
+                error: function (err) {
+                    console.error("ConfirmOtp Error:", err);
+                    setLoginLoading(el.confirmButton, false, "Confirm OTP");
+                    showLoginMessage("Confirm OTP failed. Please check API service and OTP.", false);
+                }
+            });
+        }
+    </script>
+}

+ 3 - 3
Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/Views/MonthlyWinners/Index.cshtml

@@ -33,7 +33,7 @@
 
             <div class="monthly-prize-list">
                 <article class="monthly-prize-card is-first">
-                    <img class="monthly-medal" src="~/kitty/assets/kitty-fall/monthly-medal-1.png" alt="">
+                    <img class="monthly-medal" src="~/kitty/assets/kitty-fall/monthlymedal1.png" alt="">
                     <div class="monthly-prize-copy">
                         <h2>First Prize</h2>
                         <strong>0123 *** 789</strong>
@@ -46,7 +46,7 @@
                 </article>
 
                 <article class="monthly-prize-card is-second">
-                    <img class="monthly-medal" src="~/kitty/assets/kitty-fall/monthly-medal-2.png" alt="">
+                    <img class="monthly-medal" src="~/kitty/assets/kitty-fall/monthlymedal2.png" alt="">
                     <div class="monthly-prize-copy">
                         <h2>Second Prize</h2>
                         <strong>0123 *** 789</strong>
@@ -59,7 +59,7 @@
                 </article>
 
                 <article class="monthly-prize-card is-third">
-                    <img class="monthly-medal" src="~/kitty/assets/kitty-fall/monthly-medal-3.png" alt="">
+                    <img class="monthly-medal" src="~/kitty/assets/kitty-fall/monthlymedal3.png" alt="">
                     <div class="monthly-prize-copy">
                         <h2>Third Prize</h2>
                         <strong>0123 *** 789</strong>

+ 66 - 0
Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/Views/Shared/_Layout.cshtml

@@ -22,9 +22,30 @@
     var navShellClass = isHome
         ? "bottom-nav-shell home-screen scale-floor"
         : "bottom-nav-shell scale-floor";
+    var currentCulture = System.Globalization.CultureInfo.CurrentUICulture.Name.ToLower().StartsWith("vi") ? "vi" : "en";
 }
     @RenderBody()
 
+    <div class="language-menu-modal" data-language-menu aria-hidden="true">
+        <button class="language-menu-backdrop" type="button" data-language-close aria-label="Close language menu"></button>
+        <section class="language-menu-card" role="dialog" aria-modal="true" aria-label="Language">
+            <button class="language-menu-close" type="button" data-language-close aria-label="Close language menu">x</button>
+            <div class="language-menu-title">Language</div>
+            <div class="language-menu-options">
+                <button class="language-menu-option @(currentCulture == "en" ? "active" : null)" type="button" onclick="changeLang('en')">
+                    <img src="https://flagcdn.com/w40/gb.png" alt="English">
+                    <span>English</span>
+                    <strong>EN</strong>
+                </button>
+                <button class="language-menu-option @(currentCulture == "vi" ? "active" : null)" type="button" onclick="changeLang('vi')">
+                    <img src="https://flagcdn.com/w40/mm.png" alt="Myanmar">
+                    <span>Myanmar</span>
+                    <strong>MM</strong>
+                </button>
+            </div>
+        </section>
+    </div>
+
     @if (showBottomNav)
     {
         <nav class="@navShellClass" data-scale-floor="368" data-scale-height="60" data-scale-var="--bottom-nav-height" aria-label="Main navigation">
@@ -55,6 +76,51 @@
 
     <script src="~/kitty/lib/jquery/jquery.min.js" asp-append-version="true"></script>
     <script src="~/kitty/js/all.min.js" asp-append-version="true"></script>
+    <script>
+        function changeLang(lang) {
+            $.ajax({
+                url: "/Home/ChangeLanguage",
+                data: { lang: lang },
+                success: function () { location.reload(); }
+            });
+        }
+
+        (function () {
+            const languageMenu = document.querySelector("[data-language-menu]");
+            if (!languageMenu) return;
+
+            const openLanguageMenu = function (event) {
+                event.preventDefault();
+                event.stopPropagation();
+                languageMenu.classList.add("is-open");
+                languageMenu.setAttribute("aria-hidden", "false");
+                requestAnimationFrame(function () {
+                    languageMenu.querySelector(".language-menu-option.active")?.focus({ preventScroll: true });
+                });
+            };
+
+            const closeLanguageMenu = function (event) {
+                event?.preventDefault();
+                event?.stopPropagation();
+                languageMenu.classList.remove("is-open");
+                languageMenu.setAttribute("aria-hidden", "true");
+            };
+
+            document.querySelectorAll(".menu-button").forEach(function (button) {
+                button.addEventListener("click", openLanguageMenu);
+            });
+
+            languageMenu.querySelectorAll("[data-language-close]").forEach(function (button) {
+                button.addEventListener("click", closeLanguageMenu);
+            });
+
+            document.addEventListener("keydown", function (event) {
+                if (event.key === "Escape" && languageMenu.classList.contains("is-open")) {
+                    closeLanguageMenu(event);
+                }
+            });
+        })();
+    </script>
     @await RenderSectionAsync("Scripts", required: false)
 </body>
 </html>

+ 0 - 0
Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/wwwroot/kitty/assets/kitty-fall/monthly-medal-1.png → Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/wwwroot/kitty/assets/kitty-fall/monthlymedal1.png


+ 0 - 0
Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/wwwroot/kitty/assets/kitty-fall/monthly-medal-2.png → Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/wwwroot/kitty/assets/kitty-fall/monthlymedal2.png


+ 0 - 0
Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/wwwroot/kitty/assets/kitty-fall/monthly-medal-3.png → Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/wwwroot/kitty/assets/kitty-fall/monthlymedal3.png


+ 0 - 0
Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/wwwroot/kitty/assets/kitty-fall/rank-medal-1.png → Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/wwwroot/kitty/assets/kitty-fall/rankmedal1.png


+ 0 - 0
Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/wwwroot/kitty/assets/kitty-fall/rank-medal-2.png → Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/wwwroot/kitty/assets/kitty-fall/rankmedal2.png


+ 0 - 0
Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/wwwroot/kitty/assets/kitty-fall/rank-medal-3.png → Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/wwwroot/kitty/assets/kitty-fall/rankmedal3.png


+ 310 - 0
Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/wwwroot/kitty/css/all.min.css

@@ -723,6 +723,12 @@ button {
   font-weight: 400;
 }
 
+.topbar-actions {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
 .menu-button {
   width: 32px;
   height: 32px;
@@ -743,6 +749,25 @@ button {
   box-shadow: 0 0 0 1px rgba(104, 147, 248, 0.35);
 }
 
+.home-back-button {
+  width: 32px;
+  height: 32px;
+  border: 1px solid #fff;
+  border-radius: 8px;
+  display: grid;
+  place-items: center;
+  background: rgba(255, 255, 255, 0.4);
+}
+
+.home-back-button span {
+  width: 12px;
+  height: 12px;
+  border-left: 3px solid #fff;
+  border-bottom: 3px solid #fff;
+  transform: translateX(2px) rotate(45deg);
+  filter: drop-shadow(0 0 1px rgba(104, 147, 248, 0.6));
+}
+
 .play-top-stats {
   display: none;
   align-items: center;
@@ -3078,6 +3103,110 @@ button {
   -webkit-backdrop-filter: blur(6px);
 }
 
+.language-menu-modal {
+  position: fixed;
+  z-index: 40;
+  inset: 0;
+  display: grid;
+  place-items: center;
+  visibility: hidden;
+  opacity: 0;
+  pointer-events: none;
+  transition: opacity 0.18s ease, visibility 0.18s ease;
+}
+
+.language-menu-modal.is-open {
+  visibility: visible;
+  opacity: 1;
+  pointer-events: auto;
+}
+
+.language-menu-backdrop {
+  position: absolute;
+  inset: 0;
+  border: 0;
+  background: rgba(20, 18, 29, 0.45);
+  backdrop-filter: blur(5px);
+  -webkit-backdrop-filter: blur(5px);
+}
+
+.language-menu-card {
+  position: relative;
+  width: min(calc(100vw - 48px), 330px);
+  border: 3px solid #d832c5;
+  border-radius: 24px;
+  padding: 22px 18px 18px;
+  background: linear-gradient(180deg, #fff7fb 0%, #f6e3ff 100%);
+  box-shadow: 0 18px 45px rgba(52, 18, 125, 0.28);
+  color: #5721ea;
+}
+
+.language-menu-close {
+  position: absolute;
+  top: 10px;
+  right: 10px;
+  width: 28px;
+  height: 28px;
+  border: 0;
+  border-radius: 50%;
+  background: #5721ea;
+  color: #fff;
+  font-size: 18px;
+  font-weight: 900;
+  line-height: 1;
+}
+
+.language-menu-title {
+  margin-bottom: 16px;
+  padding-right: 30px;
+  font-size: 22px;
+  font-weight: 900;
+  color: #5721ea;
+}
+
+.language-menu-options {
+  display: grid;
+  gap: 10px;
+}
+
+.language-menu-option {
+  min-height: 58px;
+  border: 2px solid rgba(87, 33, 234, 0.2);
+  border-radius: 16px;
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 10px 12px;
+  background: rgba(255, 255, 255, 0.78);
+  color: #271259;
+  font-weight: 900;
+  text-align: left;
+}
+
+.language-menu-option.active {
+  border-color: #fb0043;
+  background: #fff;
+  color: #fb0043;
+  box-shadow: 0 8px 18px rgba(251, 0, 67, 0.14);
+}
+
+.language-menu-option img {
+  width: 28px;
+  height: auto;
+  border-radius: 4px;
+  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.16);
+}
+
+.language-menu-option span {
+  flex: 1;
+  font-size: 15px;
+}
+
+.language-menu-option strong {
+  font-size: 12px;
+  letter-spacing: 0;
+}
+
 .confirm-dialog-shell {
   position: absolute;
   z-index: 1;
@@ -3508,6 +3637,187 @@ button {
   bottom: 28px;
 }
 
+.login-screen {
+  --bg-offset-ratio: -12%;
+  display: grid;
+  align-items: center;
+  padding: 28px 22px;
+}
+
+.login-panel {
+  position: relative;
+  z-index: 1;
+  display: grid;
+  justify-items: center;
+  gap: 12px;
+}
+
+.login-logo {
+  width: 210px;
+  height: auto;
+  -o-object-fit: contain;
+     object-fit: contain;
+  filter: drop-shadow(0 5px 0 rgba(94, 40, 148, 0.18));
+}
+
+.login-cat {
+  width: 132px;
+  height: auto;
+  -o-object-fit: contain;
+     object-fit: contain;
+  margin-bottom: -28px;
+  transform: rotate(-4deg);
+}
+
+.login-card {
+  width: min(100%, 382px);
+  border: 3px solid #6b31da;
+  border-radius: 28px;
+  padding: 52px 22px 22px;
+  background: rgba(255, 246, 249, 0.94);
+  box-shadow: 0 5px 0 rgba(164, 69, 183, 0.35);
+}
+
+.login-card-head {
+  display: grid;
+  gap: 6px;
+  text-align: center;
+}
+
+.login-card-head strong {
+  color: var(--violet);
+  font-size: 24px;
+  line-height: 1;
+  font-weight: 900;
+}
+
+.login-card-head span {
+  color: #5e4a87;
+  font-size: 13px;
+  line-height: 1.25;
+  font-weight: 700;
+}
+
+.login-session {
+  margin-top: 16px;
+  display: grid;
+  grid-template-columns: 1fr auto;
+  align-items: center;
+  gap: 4px 10px;
+  padding: 10px 12px;
+  border: 2px solid rgba(87, 33, 234, 0.18);
+  border-radius: 16px;
+  background: #fff;
+}
+
+.login-session span {
+  color: #7b6aa2;
+  font-size: 11px;
+  font-weight: 800;
+}
+
+.login-session strong {
+  color: var(--violet);
+  font-size: 14px;
+  font-weight: 900;
+}
+
+.login-session form {
+  grid-row: 1 / span 2;
+  grid-column: 2;
+}
+
+.login-session button {
+  height: 32px;
+  border: 0;
+  border-radius: 16px;
+  padding: 0 14px;
+  background: #efe8ff;
+  color: var(--violet);
+  font-size: 12px;
+  font-weight: 900;
+}
+
+.login-alert {
+  margin-top: 16px;
+  border: 2px solid rgba(251, 0, 67, 0.28);
+  border-radius: 16px;
+  padding: 10px 12px;
+  background: #fff4f7;
+  color: var(--pink);
+  font-size: 12px;
+  line-height: 1.25;
+  font-weight: 800;
+  text-align: center;
+}
+
+.login-alert-info {
+  border-color: rgba(87, 33, 234, 0.2);
+  background: #f5f1ff;
+  color: var(--violet);
+}
+
+.login-hidden {
+  display: none !important;
+}
+
+.login-form {
+  margin-top: 18px;
+  display: grid;
+  gap: 14px;
+}
+
+.login-field {
+  display: grid;
+  gap: 6px;
+}
+
+.login-field span {
+  color: var(--violet);
+  font-size: 12px;
+  font-weight: 900;
+}
+
+.login-field input {
+  width: 100%;
+  height: 46px;
+  border: 2px solid rgba(87, 33, 234, 0.24);
+  border-radius: 18px;
+  padding: 0 15px;
+  outline: none;
+  background: #fff;
+  color: #2d1c57;
+  font-size: 15px;
+  font-weight: 800;
+}
+
+.login-field input:focus {
+  border-color: var(--violet);
+  box-shadow: 0 0 0 4px rgba(87, 33, 234, 0.12);
+}
+
+.login-submit {
+  height: 52px;
+  border: 0;
+  border-radius: 26px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 10px;
+  background: linear-gradient(180deg, #ff2b68 0%, var(--pink) 100%);
+  color: #fff;
+  font-size: 17px;
+  font-weight: 900;
+  box-shadow: 0 5px 0 #c50035;
+}
+
+.login-submit img {
+  width: 24px;
+  height: 24px;
+  -o-object-fit: contain;
+     object-fit: contain;
+}
+
 @media (max-width: 499px), (max-height: 719px) {
   .kitty-app {
     width: min(100vw, 500px);

+ 307 - 0
Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/wwwroot/kitty/css/input.css

@@ -136,6 +136,12 @@ button {
     font-weight: 400;
 }
 
+.topbar-actions {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+}
+
 .menu-button {
     width: 32px;
     height: 32px;
@@ -156,6 +162,25 @@ button {
     box-shadow: 0 0 0 1px rgba(104, 147, 248, 0.35);
 }
 
+.home-back-button {
+    width: 32px;
+    height: 32px;
+    border: 1px solid #fff;
+    border-radius: 8px;
+    display: grid;
+    place-items: center;
+    background: rgba(255, 255, 255, 0.4);
+}
+
+.home-back-button span {
+    width: 12px;
+    height: 12px;
+    border-left: 3px solid #fff;
+    border-bottom: 3px solid #fff;
+    transform: translateX(2px) rotate(45deg);
+    filter: drop-shadow(0 0 1px rgba(104, 147, 248, 0.6));
+}
+
 .play-top-stats {
     display: none;
     align-items: center;
@@ -2461,6 +2486,110 @@ button {
     -webkit-backdrop-filter: blur(6px);
 }
 
+.language-menu-modal {
+    position: fixed;
+    z-index: 40;
+    inset: 0;
+    display: grid;
+    place-items: center;
+    visibility: hidden;
+    opacity: 0;
+    pointer-events: none;
+    transition: opacity 0.18s ease, visibility 0.18s ease;
+}
+
+.language-menu-modal.is-open {
+    visibility: visible;
+    opacity: 1;
+    pointer-events: auto;
+}
+
+.language-menu-backdrop {
+    position: absolute;
+    inset: 0;
+    border: 0;
+    background: rgba(20, 18, 29, 0.45);
+    backdrop-filter: blur(5px);
+    -webkit-backdrop-filter: blur(5px);
+}
+
+.language-menu-card {
+    position: relative;
+    width: min(calc(100vw - 48px), 330px);
+    border: 3px solid #d832c5;
+    border-radius: 24px;
+    padding: 22px 18px 18px;
+    background: linear-gradient(180deg, #fff7fb 0%, #f6e3ff 100%);
+    box-shadow: 0 18px 45px rgba(52, 18, 125, 0.28);
+    color: #5721ea;
+}
+
+.language-menu-close {
+    position: absolute;
+    top: 10px;
+    right: 10px;
+    width: 28px;
+    height: 28px;
+    border: 0;
+    border-radius: 50%;
+    background: #5721ea;
+    color: #fff;
+    font-size: 18px;
+    font-weight: 900;
+    line-height: 1;
+}
+
+.language-menu-title {
+    margin-bottom: 16px;
+    padding-right: 30px;
+    font-size: 22px;
+    font-weight: 900;
+    color: #5721ea;
+}
+
+.language-menu-options {
+    display: grid;
+    gap: 10px;
+}
+
+.language-menu-option {
+    min-height: 58px;
+    border: 2px solid rgba(87, 33, 234, 0.2);
+    border-radius: 16px;
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    padding: 10px 12px;
+    background: rgba(255, 255, 255, 0.78);
+    color: #271259;
+    font-weight: 900;
+    text-align: left;
+}
+
+.language-menu-option.active {
+    border-color: #fb0043;
+    background: #fff;
+    color: #fb0043;
+    box-shadow: 0 8px 18px rgba(251, 0, 67, 0.14);
+}
+
+.language-menu-option img {
+    width: 28px;
+    height: auto;
+    border-radius: 4px;
+    box-shadow: 0 2px 6px rgba(0, 0, 0, 0.16);
+}
+
+.language-menu-option span {
+    flex: 1;
+    font-size: 15px;
+}
+
+.language-menu-option strong {
+    font-size: 12px;
+    letter-spacing: 0;
+}
+
 .confirm-dialog-shell {
     position: absolute;
     z-index: 1;
@@ -2884,6 +3013,184 @@ button {
     bottom: 28px;
 }
 
+.login-screen {
+    --bg-offset-ratio: -12%;
+    display: grid;
+    align-items: center;
+    padding: 28px 22px;
+}
+
+.login-panel {
+    position: relative;
+    z-index: 1;
+    display: grid;
+    justify-items: center;
+    gap: 12px;
+}
+
+.login-logo {
+    width: 210px;
+    height: auto;
+    object-fit: contain;
+    filter: drop-shadow(0 5px 0 rgba(94, 40, 148, 0.18));
+}
+
+.login-cat {
+    width: 132px;
+    height: auto;
+    object-fit: contain;
+    margin-bottom: -28px;
+    transform: rotate(-4deg);
+}
+
+.login-card {
+    width: min(100%, 382px);
+    border: 3px solid #6b31da;
+    border-radius: 28px;
+    padding: 52px 22px 22px;
+    background: rgba(255, 246, 249, 0.94);
+    box-shadow: 0 5px 0 rgba(164, 69, 183, 0.35);
+}
+
+.login-card-head {
+    display: grid;
+    gap: 6px;
+    text-align: center;
+}
+
+.login-card-head strong {
+    color: var(--violet);
+    font-size: 24px;
+    line-height: 1;
+    font-weight: 900;
+}
+
+.login-card-head span {
+    color: #5e4a87;
+    font-size: 13px;
+    line-height: 1.25;
+    font-weight: 700;
+}
+
+.login-session {
+    margin-top: 16px;
+    display: grid;
+    grid-template-columns: 1fr auto;
+    align-items: center;
+    gap: 4px 10px;
+    padding: 10px 12px;
+    border: 2px solid rgba(87, 33, 234, 0.18);
+    border-radius: 16px;
+    background: #fff;
+}
+
+.login-session span {
+    color: #7b6aa2;
+    font-size: 11px;
+    font-weight: 800;
+}
+
+.login-session strong {
+    color: var(--violet);
+    font-size: 14px;
+    font-weight: 900;
+}
+
+.login-session form {
+    grid-row: 1 / span 2;
+    grid-column: 2;
+}
+
+.login-session button {
+    height: 32px;
+    border: 0;
+    border-radius: 16px;
+    padding: 0 14px;
+    background: #efe8ff;
+    color: var(--violet);
+    font-size: 12px;
+    font-weight: 900;
+}
+
+.login-alert {
+    margin-top: 16px;
+    border: 2px solid rgba(251, 0, 67, 0.28);
+    border-radius: 16px;
+    padding: 10px 12px;
+    background: #fff4f7;
+    color: var(--pink);
+    font-size: 12px;
+    line-height: 1.25;
+    font-weight: 800;
+    text-align: center;
+}
+
+.login-alert-info {
+    border-color: rgba(87, 33, 234, 0.2);
+    background: #f5f1ff;
+    color: var(--violet);
+}
+
+.login-hidden {
+    display: none !important;
+}
+
+.login-form {
+    margin-top: 18px;
+    display: grid;
+    gap: 14px;
+}
+
+.login-field {
+    display: grid;
+    gap: 6px;
+}
+
+.login-field span {
+    color: var(--violet);
+    font-size: 12px;
+    font-weight: 900;
+}
+
+.login-field input {
+    width: 100%;
+    height: 46px;
+    border: 2px solid rgba(87, 33, 234, 0.24);
+    border-radius: 18px;
+    padding: 0 15px;
+    outline: none;
+    background: #fff;
+    color: #2d1c57;
+    font-size: 15px;
+    font-weight: 800;
+}
+
+.login-field input:focus {
+    border-color: var(--violet);
+    box-shadow: 0 0 0 4px rgba(87, 33, 234, 0.12);
+}
+
+.login-submit {
+    height: 52px;
+    border: 0;
+    border-radius: 26px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    gap: 10px;
+    background: linear-gradient(180deg, #ff2b68 0%, var(--pink) 100%);
+    color: #fff;
+    font-size: 17px;
+    font-weight: 900;
+    box-shadow: 0 5px 0 #c50035;
+}
+
+.login-submit img {
+    width: 24px;
+    height: 24px;
+    object-fit: contain;
+}
+
 @media (max-width: 499px), (max-height: 719px) {
     .kitty-app {
         width: min(100vw, 500px);

+ 3 - 3
Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/wwwroot/kitty/js/all.min.js

@@ -75,7 +75,7 @@ const initKittyFall = () => {
       if (rankValue < 1 || rankValue > 3) {
         return "";
       }
-      return `assets/kitty-fall/rank-medal-${rankValue}.png`;
+      return `/kitty/assets/kitty-fall/rankmedal${rankValue}.png`;
     };
     const formatScore = value => value.toLocaleString("en-US");
     const maskedPhone = rankValue => `08** *** ${String(780 + rankValue).slice(-3)}`;
@@ -115,7 +115,7 @@ const initKittyFall = () => {
                         <div class="leaderboard-row${isPlayer ? " is-current" : ""}" data-rank-row="${rankValue}">
                             <span class="${rankClass(rankValue)}">${renderRankBadge(rankValue)}</span>
                             <span class="leaderboard-phone">${isPlayer ? playerPhone : maskedPhone(rankValue)}</span>
-                            <strong><img src="assets/kitty-fall/leaderboard-score-icon.png" alt=""><b>${formatScore(score)}</b></strong>
+                            <strong><img src="/kitty/assets/kitty-fall/leaderboard-score-icon.png" alt=""><b>${formatScore(score)}</b></strong>
                         </div>
                     `;
         }).join("");
@@ -153,7 +153,7 @@ const initKittyFall = () => {
                         <span><b>${day}/06/2026</b><small>20:${minute}:45</small></span>
                         <span><b>Play game</b><small class="${level.className}">${level.label}</small></span>
                         <span>Passed level ${78 - rowNumber}</span>
-                        <strong class="reward-pill is-${reward.type}"><img src="assets/kitty-fall/${reward.icon}" alt=""><b>${reward.value}</b><small>${reward.label}</small></strong>
+                        <strong class="reward-pill is-${reward.type}"><img src="/kitty/assets/kitty-fall/${reward.icon}" alt=""><b>${reward.value}</b><small>${reward.label}</small></strong>
                     </div>
                 `;
       }).join("");

+ 3 - 3
Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/wwwroot/kitty/js/input.js

@@ -87,7 +87,7 @@ const initKittyFall = () => {
 
         const medalSrc = (rankValue) => {
             if (rankValue < 1 || rankValue > 3) return "";
-            return `assets/kitty-fall/rank-medal-${rankValue}.png`;
+            return `/kitty/assets/kitty-fall/rankmedal${rankValue}.png`;
         };
 
         const formatScore = (value) => value.toLocaleString("en-US");
@@ -130,7 +130,7 @@ const initKittyFall = () => {
                         <div class="leaderboard-row${isPlayer ? " is-current" : ""}" data-rank-row="${rankValue}">
                             <span class="${rankClass(rankValue)}">${renderRankBadge(rankValue)}</span>
                             <span class="leaderboard-phone">${isPlayer ? playerPhone : maskedPhone(rankValue)}</span>
-                            <strong><img src="assets/kitty-fall/leaderboard-score-icon.png" alt=""><b>${formatScore(score)}</b></strong>
+                            <strong><img src="/kitty/assets/kitty-fall/leaderboard-score-icon.png" alt=""><b>${formatScore(score)}</b></strong>
                         </div>
                     `;
                 }).join("");
@@ -186,7 +186,7 @@ const initKittyFall = () => {
                         <span><b>${day}/06/2026</b><small>20:${minute}:45</small></span>
                         <span><b>Play game</b><small class="${level.className}">${level.label}</small></span>
                         <span>Passed level ${78 - rowNumber}</span>
-                        <strong class="reward-pill is-${reward.type}"><img src="assets/kitty-fall/${reward.icon}" alt=""><b>${reward.value}</b><small>${reward.label}</small></strong>
+                        <strong class="reward-pill is-${reward.type}"><img src="/kitty/assets/kitty-fall/${reward.icon}" alt=""><b>${reward.value}</b><small>${reward.label}</small></strong>
                     </div>
                 `;
             }).join("");

+ 32 - 0
Kitty_Fall/Kitty_Fall/Kitty_Fall.Woker/Kitty_Fall.Woker.csproj

@@ -0,0 +1,32 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <TargetFramework>net7.0</TargetFramework>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <Nullable>enable</Nullable>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <Content Include="appsettings.Development.json">
+      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+      <ExcludeFromSingleFile>true</ExcludeFromSingleFile>
+      <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
+    </Content>
+    <Content Include="appsettings.json">
+      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+      <ExcludeFromSingleFile>true</ExcludeFromSingleFile>
+      <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
+    </Content>
+    <Content Include="log4net.config">
+      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+      <ExcludeFromSingleFile>true</ExcludeFromSingleFile>
+      <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
+    </Content>
+  </ItemGroup>
+
+  <ItemGroup>
+    <ProjectReference Include="..\Common\Common.csproj" />
+    <ProjectReference Include="..\Database\Database.csproj" />
+  </ItemGroup>
+
+</Project>

+ 43 - 0
Kitty_Fall/Kitty_Fall/Kitty_Fall.Woker/Program.cs

@@ -0,0 +1,43 @@
+using Database.Database;
+using log4net;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using System.Reflection;
+using System.Xml;
+
+Environment.SetEnvironmentVariable("ORA_SDTZ", "UTC");
+
+XmlDocument log4netConfig = new XmlDocument();
+log4netConfig.Load(File.OpenRead("log4net.config"));
+var repo = log4net.LogManager.CreateRepository(
+    Assembly.GetEntryAssembly(),
+    typeof(log4net.Repository.Hierarchy.Hierarchy)
+);
+log4net.Config.XmlConfigurator.Configure(repo, log4netConfig["log4net"]);
+
+var builder = WebApplication.CreateBuilder(args);
+
+var oracleSessionTimeZone = builder.Configuration["OracleSessionTimeZone"];
+if (!string.IsNullOrWhiteSpace(oracleSessionTimeZone))
+{
+    Environment.SetEnvironmentVariable("ORA_SDTZ", oracleSessionTimeZone);
+}
+
+// Add services to the container.
+builder.Services.AddDbContext<ModelContext>(options =>
+    options.UseOracle(builder.Configuration["Connection"]));
+
+
+builder.Services.AddEndpointsApiExplorer();
+
+var app = builder.Build();
+
+// app.UseHttpsRedirection();
+
+app.UseAuthorization();
+
+app.MapControllers();
+
+app.Run();

+ 28 - 0
Kitty_Fall/Kitty_Fall/Kitty_Fall.Woker/appsettings.Development.json

@@ -0,0 +1,28 @@
+{
+  //"Connection": "Data Source=(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=127.0.0.1)(PORT=1521))(CONNECT_DATA=(SERVICE_NAME=ORA12C)));User Id=myjob;Password=HHwx4ISVN51cDam;Connection Timeout=120;",
+  "Connection": "Data Source=(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=127.0.0.1)(PORT=1539))(CONNECT_DATA=(SERVICE_NAME=ORA12C)));User Id=myjob;Password=123456;Connection Timeout=120;",
+  "OracleSessionTimeZone": "UTC",
+
+  "wsUrl": "http://127.0.0.1:8199/MyJobWs?wsdl",
+  "wsUser": "ws_myjob",
+  "wsPass": "Myjob@123A",
+  "Logging": {
+    "LogLevel": {
+      "Default": "Information",
+      "Microsoft.AspNetCore": "Warning"
+    }
+  },
+  "Kestrel": {
+    "EndPoints": {
+      "Http": {
+        "Url": "http://127.0.0.1:8311"
+      }
+    }
+  },
+  "Jwt": {
+    "Key": "MyJobApiJwtSecretKey-2026-CHANGE-THIS-IN-PRODUCTION-aB7xK9pQ2wL5nR8",
+    "Issuer": "MyJobApi",
+    "Audience": "MyJobClient"
+  },
+  "AllowedHosts": "*"
+}

+ 28 - 0
Kitty_Fall/Kitty_Fall/Kitty_Fall.Woker/appsettings.json

@@ -0,0 +1,28 @@
+{
+  //"Connection": "Data Source=(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=127.0.0.1)(PORT=1521))(CONNECT_DATA=(SERVICE_NAME=ORA12C)));User Id=myjob;Password=HHwx4ISVN51cDam;Connection Timeout=120;",
+  "Connection": "Data Source=(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=127.0.0.1)(PORT=1539))(CONNECT_DATA=(SERVICE_NAME=ORA12C)));User Id=myjob;Password=123456;Connection Timeout=120;",
+  "OracleSessionTimeZone": "UTC",
+  
+  "wsUrl": "http://127.0.0.1:8199/MyJobWs?wsdl",
+  "wsUser": "ws_myjob",
+  "wsPass": "Myjob@123A",
+  "Logging": {
+    "LogLevel": {
+      "Default": "Information",
+      "Microsoft.AspNetCore": "Warning"
+    }
+  },
+  "Kestrel": {
+    "EndPoints": {
+      "Http": {
+        "Url": "http://127.0.0.1:8311"
+      }
+    }
+  },
+  "Jwt": {
+    "Key": "MyJobApiJwtSecretKey-2026-CHANGE-THIS-IN-PRODUCTION-aB7xK9pQ2wL5nR8",
+    "Issuer": "MyJobApi",
+    "Audience": "MyJobClient"
+  },
+  "AllowedHosts": "*"
+}

+ 30 - 0
Kitty_Fall/Kitty_Fall/Kitty_Fall.Woker/log4net.config

@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<log4net>
+	<root>
+		<appender-ref ref="console" />
+		<appender-ref ref="file" />
+	</root>
+	<appender name="console" type="log4net.Appender.ConsoleAppender">
+		<layout type="log4net.Layout.PatternLayout">
+			<conversionPattern value="%date %level - %message%newline" />
+		</layout>
+		<threshold value="ALL" />
+	</appender>
+	<!-- Custom appender: mỗi ngày 1 file + tự xóa, chỉ giữ lại maxNumberOfFiles file gần nhất -->
+	<appender name="file" type="Common.DailyRollingFileAppender, Common">
+		<lockingModel type="log4net.Appender.FileAppender+MinimalLock" />
+		<!-- Thư mục chứa log (phải kết thúc bằng dấu \) -->
+		<file value="../../logs/" />
+		<appendToFile value="true" />
+		<!-- Rolling theo NGÀY: mỗi ngày 1 file riêng, vd 21.05.2026.log -->
+		<rollingStyle value="Date" />
+		<datePattern value="dd.MM.yyyy'.log'" />
+		<staticLogFileName value="false" />
+		<!-- Chỉ giữ 30 file log gần nhất; file cũ hơn sẽ TỰ ĐỘNG bị xóa -->
+		<maxNumberOfFiles value="30" />
+		<layout type="log4net.Layout.PatternLayout">
+			<conversionPattern value="%date [%thread] %level - %message%newline" />
+		</layout>
+		<threshold value="ALL" />
+	</appender>
+</log4net>

+ 6 - 0
Kitty_Fall/Kitty_Fall/Kitty_Fall.sln

@@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Database", "Database\Databa
 EndProject
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Common", "Common\Common.csproj", "{8C4AA2DF-D7F9-4552-8F0F-B4D8621A196F}"
 EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kitty_Fall.Woker", "Kitty_Fall.Woker\Kitty_Fall.Woker.csproj", "{8A1DFF29-A6F8-41D7-A8A7-6C3217D4AB0D}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
@@ -33,6 +35,10 @@ Global
 		{8C4AA2DF-D7F9-4552-8F0F-B4D8621A196F}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{8C4AA2DF-D7F9-4552-8F0F-B4D8621A196F}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{8C4AA2DF-D7F9-4552-8F0F-B4D8621A196F}.Release|Any CPU.Build.0 = Release|Any CPU
+		{8A1DFF29-A6F8-41D7-A8A7-6C3217D4AB0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{8A1DFF29-A6F8-41D7-A8A7-6C3217D4AB0D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{8A1DFF29-A6F8-41D7-A8A7-6C3217D4AB0D}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{8A1DFF29-A6F8-41D7-A8A7-6C3217D4AB0D}.Release|Any CPU.Build.0 = Release|Any CPU
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE

+ 54 - 0
database_usesAndSub.sql

@@ -986,3 +986,57 @@ Insert into WS_USER (WS_USER_ID,USERNAME,PASSWORD,IP_ADDRESS) values (1,'ws_myjo
   ALTER TABLE "TRANSACTION_ACTION" ADD CONSTRAINT "SYS_C00135761" CHECK ("CREATED_TIME" IS NOT NULL) ENABLE;
   ALTER TABLE "TRANSACTION_ACTION" ADD CONSTRAINT "SYS_C00135762" CHECK ("VALID_TIME" IS NOT NULL) ENABLE;
   ALTER TABLE "TRANSACTION_ACTION" ADD CONSTRAINT "SYS_C00135763" CHECK ("TIME_LEFT" IS NOT NULL) ENABLE;
+
+
+
+create or replace NONEDITIONABLE PROCEDURE "ADD_TURN" 
+(
+  MSISDN_ IN VARCHAR2 
+, TURN_ IN NUMBER 
+) IS
+    v_count     NUMBER;
+    v_lucky_id  NUMBER; 
+BEGIN
+ -- Ki?m tra xem ?ã t?n t?i b?n ghi cho msisdn ch?a
+    SELECT COUNT(*) INTO v_count
+    FROM lucky_spin
+    WHERE msisdn = MSISDN_ ;
+
+    IF v_count > 0 THEN
+        -- L?y ID b?n ghi ?? c?p nh?t
+        SELECT id INTO v_lucky_id
+        FROM lucky_spin
+        WHERE msisdn = MSISDN_ AND ROWNUM = 1;
+
+        -- C?p nh?t l??t quay
+        UPDATE lucky_spin
+        SET added = NVL(added, 0) + TURN_,
+            last_update = SYSDATE
+        WHERE id = v_lucky_id;
+
+    ELSE
+        -- N?u ch?a có b?n ghi ? insert m?i
+        INSERT INTO lucky_spin (
+            id,
+            msisdn,
+            added,
+            used,
+            INSERT_TIME,
+            expire_time,
+            last_update,
+            channel_add
+        ) VALUES (
+            lucky_spin_seq.NEXTVAL,
+            MSISDN_,
+            TURN_,
+            0,
+            SYSDATE,
+            SYSDATE + 7,
+            SYSDATE,
+            1
+        );
+
+    END IF;
+
+    COMMIT;
+END ADD_TURN;