diff --git a/docker-compose.yml b/docker-compose.yml
index 7ec3541..1844433 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -67,6 +67,10 @@ services:
Sms__Provider: ${SMS_PROVIDER:-kavenegar}
Sms__ApiKey: ${SMS_API_KEY:-}
Sms__Template: ${SMS_TEMPLATE:-hokmotp}
+ # Store-review test login (Google Play / Bazaar / Myket): this phone skips
+ # SMS and always accepts the static code. Give these to the review team.
+ Sms__TestPhone: ${SMS_TEST_PHONE:-09120000000}
+ Sms__TestCode: ${SMS_TEST_CODE:-453115}
# Admin panel (marketing-site links editor) — shared-token auth.
Admin__Token: ${ADMIN_TOKEN:-}
# Where the admin-editable site-links JSON is persisted (mounted volume).
diff --git a/server/src/Hokm.Server/Auth/OtpService.cs b/server/src/Hokm.Server/Auth/OtpService.cs
index 4db5716..4d72ec8 100644
--- a/server/src/Hokm.Server/Auth/OtpService.cs
+++ b/server/src/Hokm.Server/Auth/OtpService.cs
@@ -18,6 +18,14 @@ public sealed class SmsOptions
public string DevCode { get; set; } = "1234";
public int TtlSeconds { get; set; } = 120;
+ ///
+ /// A reviewer/test login (Google Play, Bazaar, Myket): this exact phone never
+ /// triggers a real SMS and always accepts . Give these
+ /// to the store's review team. Set TestPhone empty to disable.
+ ///
+ public string TestPhone { get; set; } = "09120000000";
+ public string TestCode { get; set; } = "453115";
+
/* --- Rate limiting (applies to real SMS sends only; dev mode is unlimited) --- */
/// Minimum seconds between two OTP sends to the same phone (resend cooldown).
public int ResendCooldownSeconds { get; set; } = 60;
@@ -54,12 +62,18 @@ public sealed class OtpService
/// Dev mode = explicitly on, or no API key configured.
public bool IsDev => _opts.DevMode || string.IsNullOrWhiteSpace(_opts.ApiKey);
+ private bool IsTestPhone(string normalizedPhone) =>
+ !string.IsNullOrWhiteSpace(_opts.TestPhone) && normalizedPhone == Normalize(_opts.TestPhone);
+
/// Generate a code, store it, and send the SMS. Returns devCode only in dev mode.
public async Task Request(string phone)
{
phone = Normalize(phone);
if (string.IsNullOrWhiteSpace(phone)) return new OtpResult(false, null, "INVALID_PHONE", 0);
+ // Store review/test login: never send an SMS for the designated test number.
+ if (IsTestPhone(phone)) return new OtpResult(true, null, null, 0);
+
// Dev mode never sends an SMS (fixed code) → no cost, no rate limit.
if (IsDev)
{
@@ -131,6 +145,7 @@ public sealed class OtpService
public bool Verify(string phone, string code)
{
phone = Normalize(phone);
+ if (IsTestPhone(phone)) return code == _opts.TestCode; // store-review test login
if (IsDev && code == _opts.DevCode) return true;
if (!_codes.TryGetValue(phone, out var e)) return false;
if (DateTime.UtcNow > e.Expires) { _codes.TryRemove(phone, out _); return false; }
diff --git a/src/components/screens/MatchmakingScreen.tsx b/src/components/screens/MatchmakingScreen.tsx
index d0c17a4..9c8074a 100644
--- a/src/components/screens/MatchmakingScreen.tsx
+++ b/src/components/screens/MatchmakingScreen.tsx
@@ -122,7 +122,11 @@ export function MatchmakingScreen() {
{searching && (
<>
{elapsed}s
- {t("mm.fillHint")}
+ {elapsed >= 28 ? (
+ {t("mm.stuck")}
+ ) : (
+ {t("mm.fillHint")}
+ )}
>
)}
diff --git a/src/components/screens/OnlineLobbyScreen.tsx b/src/components/screens/OnlineLobbyScreen.tsx
index 85032ea..3cd04b8 100644
--- a/src/components/screens/OnlineLobbyScreen.tsx
+++ b/src/components/screens/OnlineLobbyScreen.tsx
@@ -48,8 +48,10 @@ export function OnlineLobbyScreen() {
go("buycoins");
return;
}
- await startMatchmaking({ ranked: true, stake: l.entry });
+ // Navigate FIRST so the button never hangs on the SignalR handshake; the
+ // matchmaking screen shows "searching" while the connection establishes.
go("matchmaking");
+ void startMatchmaking({ ranked: true, stake: l.entry });
};
return (
diff --git a/src/lib/i18n.tsx b/src/lib/i18n.tsx
index ab00b61..1ac9bdb 100644
--- a/src/lib/i18n.tsx
+++ b/src/lib/i18n.tsx
@@ -256,6 +256,7 @@ const fa: Dict = {
"mm.found": "بازیکنان پیدا شدند!",
"mm.ready": "آماده شروع",
"mm.fillHint": "اگر بازیکن آنلاینی پیدا نشود، رباتها جایگزین میشوند",
+ "mm.stuck": "اتصال به سرور طول کشید. لطفاً «لغو» را بزنید و دوباره تلاش کنید.",
"intro.found": "بازیکنان آمادهاند!",
"intro.getReady": "بازی در حال شروع است…",
"intro.go": "شروع!",
@@ -642,6 +643,7 @@ const en: Dict = {
"mm.found": "Players found!",
"mm.ready": "Ready to start",
"mm.fillHint": "If no online players are found, bots will fill in",
+ "mm.stuck": "Connecting to the server is taking too long. Tap Cancel and try again.",
"mm.cancel": "Cancel",
"mm.start": "Enter game",