first commit
This commit is contained in:
commit
0d09ee74c3
24 changed files with 547 additions and 0 deletions
75
README.md
Normal file
75
README.md
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
# Winsoo Invite Page
|
||||
|
||||
A single-page invite/referral landing page for Winsoo. A visitor lands on the
|
||||
page via an invite link, enters their mobile number, and the page submits
|
||||
that number against the invite code embedded in the URL. Built with plain
|
||||
HTML, CSS, and JavaScript — no build step, no framework, no dependencies.
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
InvitePage/
|
||||
├── index.html Markup for the page
|
||||
├── favicon.svg Browser tab icon
|
||||
├── css/
|
||||
│ └── style.css Styles + @font-face declarations
|
||||
├── js/
|
||||
│ └── script.js OAuth token fetch + invite submission logic
|
||||
└── assets/
|
||||
├── images/
|
||||
│ ├── logo.png Winsoo wordmark
|
||||
│ └── illustration.png Hero illustration
|
||||
└── fonts/
|
||||
└── iransans/ IRANSans font family (all weights/variants)
|
||||
```
|
||||
|
||||
## Running locally
|
||||
|
||||
The page must be served over `http://` or `https://`, not opened directly
|
||||
as a `file://` URL — the invite API's CORS policy is keyed to real origins,
|
||||
and some browsers handle a `file://` "null" origin inconsistently.
|
||||
|
||||
```bash
|
||||
python3 -m http.server 8765
|
||||
```
|
||||
|
||||
Then open:
|
||||
|
||||
```
|
||||
http://localhost:8765/index.html?inviteCode=<uuid>
|
||||
```
|
||||
|
||||
The `inviteCode` query parameter is required — it's the invite code that
|
||||
gets sent to the API along with the submitted phone number.
|
||||
|
||||
## How it works
|
||||
|
||||
1. **On page load**, [`js/script.js`](js/script.js) requests an OAuth2
|
||||
access token from Gooyal's `client_credentials` token endpoint, using
|
||||
Basic auth (base64 of `client_id:client_secret`) and scope
|
||||
`accounts.invite:submit`. The token is cached in memory for reuse.
|
||||
2. **On submit**, the phone number is normalized to E.164 format:
|
||||
- Persian (`۰-۹`) and Arabic-Indic (`٠-٩`) digits are converted to Latin
|
||||
digits.
|
||||
- Common prefixes (`0`, `98`, `+98`, `0098`) are stripped and replaced
|
||||
with `+98`.
|
||||
- The result must match a 10-digit Iranian mobile number (`9XXXXXXXXX`).
|
||||
- Invalid input highlights the field and shows an inline error instead
|
||||
of calling the API.
|
||||
3. The invite code is read from the page's own URL (`?inviteCode=...`) and
|
||||
POSTed together with the mobile number to the invite request endpoint,
|
||||
authenticated with the bearer token from step 1.
|
||||
4. If the invite request fails with `401` (expired/invalid token), the
|
||||
token is refetched once and the request is retried automatically.
|
||||
Any other failure surfaces the API's own `status_message` (falling back
|
||||
to a generic Persian error) in the inline message area below the input.
|
||||
A successful submission shows a success message.
|
||||
|
||||
## Known limitation
|
||||
|
||||
`js/script.js` contains the OAuth client ID and client secret in plain
|
||||
text, which means anyone viewing the page source can read them. This is
|
||||
acceptable for the current staging environment, but **before shipping to
|
||||
production**, the `client_credentials` token exchange should move to a
|
||||
small backend/proxy that holds the secret server-side; the browser should
|
||||
never see it directly.
|
||||
BIN
assets/fonts/iransans/IRANSans(FaNum).ttf
Normal file
BIN
assets/fonts/iransans/IRANSans(FaNum).ttf
Normal file
Binary file not shown.
BIN
assets/fonts/iransans/IRANSans(FaNum)_Bold.ttf
Normal file
BIN
assets/fonts/iransans/IRANSans(FaNum)_Bold.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/iransans/IRANSans(FaNum)_Light.ttf
Normal file
BIN
assets/fonts/iransans/IRANSans(FaNum)_Light.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/iransans/IRANSans(FaNum)_Medium.ttf
Normal file
BIN
assets/fonts/iransans/IRANSans(FaNum)_Medium.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/iransans/IRANSans(FaNum)_UltraLight.ttf
Normal file
BIN
assets/fonts/iransans/IRANSans(FaNum)_UltraLight.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/iransans/IRANSans.ttf
Normal file
BIN
assets/fonts/iransans/IRANSans.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/iransans/IRANSansX-Bold.ttf
Normal file
BIN
assets/fonts/iransans/IRANSansX-Bold.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/iransans/IRANSansX-Regular.ttf
Normal file
BIN
assets/fonts/iransans/IRANSansX-Regular.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/iransans/IRANSansXFaNum-Bold.ttf
Normal file
BIN
assets/fonts/iransans/IRANSansXFaNum-Bold.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/iransans/IRANSansXFaNum-Regular.ttf
Normal file
BIN
assets/fonts/iransans/IRANSansXFaNum-Regular.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/iransans/IRANSansXNoEn-Bold.ttf
Normal file
BIN
assets/fonts/iransans/IRANSansXNoEn-Bold.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/iransans/IRANSansXNoEn-Regular.ttf
Normal file
BIN
assets/fonts/iransans/IRANSansXNoEn-Regular.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/iransans/IRANSans_Black.ttf
Normal file
BIN
assets/fonts/iransans/IRANSans_Black.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/iransans/IRANSans_Bold.ttf
Normal file
BIN
assets/fonts/iransans/IRANSans_Bold.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/iransans/IRANSans_Light.ttf
Normal file
BIN
assets/fonts/iransans/IRANSans_Light.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/iransans/IRANSans_Medium.ttf
Normal file
BIN
assets/fonts/iransans/IRANSans_Medium.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/iransans/IRANSans_UltraLight.ttf
Normal file
BIN
assets/fonts/iransans/IRANSans_UltraLight.ttf
Normal file
Binary file not shown.
BIN
assets/images/illustration.png
Normal file
BIN
assets/images/illustration.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/images/logo.png
Normal file
BIN
assets/images/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
233
css/style.css
Normal file
233
css/style.css
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
@font-face {
|
||||
font-family: "IRANSans";
|
||||
src: url("../assets/fonts/iransans/IRANSans_Light.ttf") format("truetype");
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "IRANSans";
|
||||
src: url("../assets/fonts/iransans/IRANSans.ttf") format("truetype");
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "IRANSans";
|
||||
src: url("../assets/fonts/iransans/IRANSans_Medium.ttf") format("truetype");
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "IRANSans";
|
||||
src: url("../assets/fonts/iransans/IRANSans_Bold.ttf") format("truetype");
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "IRANSans";
|
||||
src: url("../assets/fonts/iransans/IRANSans_Black.ttf") format("truetype");
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
--orange: #f2701e;
|
||||
--orange-dark: #e2650f;
|
||||
--navy: #223D5D;
|
||||
--text-primary: #17181a;
|
||||
--text-secondary: #4d4d4d;
|
||||
--text-muted: #9aa0a6;
|
||||
--border-color: #e5e3e0;
|
||||
--input-bg: #fafafa;
|
||||
--skin: #f2c6a0;
|
||||
--hair: #3b2a22;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "IRANSans", Tahoma, sans-serif;
|
||||
background: #ececec;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.screen {
|
||||
width: 100%;
|
||||
max-width: 430px;
|
||||
min-height: 100vh;
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@media (min-width: 431px) {
|
||||
body {
|
||||
align-items: flex-start;
|
||||
padding: 32px 0;
|
||||
}
|
||||
|
||||
.screen {
|
||||
min-height: 860px;
|
||||
border-radius: 28px;
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.15);
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
/* Logo */
|
||||
.logo {
|
||||
text-align: center;
|
||||
padding-top: 28px;
|
||||
}
|
||||
|
||||
.logo img {
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
}
|
||||
|
||||
/* Illustration */
|
||||
.illustration {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 8px 24px 0;
|
||||
margin-top: 46px;
|
||||
margin-bottom: 118px;
|
||||
}
|
||||
|
||||
.illustration img {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.content {
|
||||
padding: 12px 26px 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 10px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14.5px;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 16px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 13.5px;
|
||||
line-height: 2;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 22px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.description strong {
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.phone-input {
|
||||
width: 100%;
|
||||
padding: 15px 18px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--input-bg);
|
||||
font-family: inherit;
|
||||
font-size: 14.5px;
|
||||
color: var(--text-primary);
|
||||
text-align: right;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.phone-input::placeholder {
|
||||
color: #bdbdbd;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.phone-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--orange);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 11.5px;
|
||||
line-height: 1.9;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.form-message {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.8;
|
||||
margin: 10px 0 0;
|
||||
text-align: right;
|
||||
min-height: 1em;
|
||||
}
|
||||
|
||||
.form-message.error {
|
||||
color: #d64545;
|
||||
}
|
||||
|
||||
.form-message.success {
|
||||
color: #2f9e5b;
|
||||
}
|
||||
|
||||
.phone-input.invalid {
|
||||
border-color: #d64545;
|
||||
}
|
||||
|
||||
/* Footer / CTA */
|
||||
.footer {
|
||||
margin-top: auto;
|
||||
padding: 24px 26px 32px;
|
||||
}
|
||||
|
||||
.cta-btn {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
background: var(--navy);
|
||||
color: #ffffff;
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.cta-btn:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.cta-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
11
favicon.svg
Normal file
11
favicon.svg
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1080 1080">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #ff751f;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-1" d="M1070.09,540.31c-1.09-2.67-5.09-4.59-8.14-5.99-22.45-10.16-43.75-22.29-63.34-37.21-6.1-4.66-11.49-10.23-17.73-15.83-2.08,1.56-2.28,4.66-2.3,7.37-.1,12.74,2.02,24.76,12.4,33.69,3.85,3.3,7.67,6.61,11.52,9.89-59.64,.66-106.2-47.73-133.24-95.44-13.52-24.57-22.82-44.5-27.91-59.77-3.38-8.47-9.34-12.7-17.84-12.7-11.85,0-17.77,5.95-17.77,17.84-5.52,79.61-25.24,194.53-55.19,281.15-.96,2.08-2.54,6.38-4.92,13.5-.28,.83-.56,1.65-.84,2.47-2.17,5.75-4.38,11.33-6.64,16.74-13.22,29.1-27.95,39.62-34.55,43.08-6.27,3-13.05,4.63-19.83,3.95-86.54-8.68-58.71-197.88-55.94-269.4,5.08-54.23,10.17-100.83,15.27-139.8,7.27-45.84-19.02-84.14-63.51-94.06l-25.48-5.07c-2.38-.52-4.73-.86-7.05-1.06-.86-.12-1.77-.23-2.79-.31-41.36-3.39-50.02,37.77-50.02,37.77l.09-.03c-.44,1.41-.84,2.85-1.18,4.31-6.48,34.64-15.2,100.22-19.23,147.71-18.5,105.18-57.1,227.66-92.66,336.46-25.85,72.75-45.21,122.97-82.49,180.27-5.7,7.69-11.01,13.24-15.8,17.21-104.33,78.06-86.04-370.85-71.98-447.87,11.89-110.13,34.78-223.66,68.65-340.55,17.15-43.95-2.31-98-40.68-121.96l-35.54-25.47c-28.07-24.83-77.44-9.66-86.43,25.47C33.68,271-12.57,557.64,11.49,801.49c10.99,87.48,29.06,183.36,94.07,235.07h-.03s39.57,33.41,72.87,34.42c12.21,.37,23.03-1.19,31.85-3.31,6.53-1.42,9.42-2.67,9.42-2.67h-.01c9.54-3.15,15.07-6.34,15.07-6.34h-.01c35.73-18.04,66.4-55.28,93.23-94.53l15.2-25.41c55.51-99.11,97.33-229.81,127.1-340.55,6.66,69.04,22.14,150.75,63.52,203.32,49.44,57.23,135.11,69.1,184.3,6.39,74.09-89.58,98.05-242.26,115.65-352.07,18.58,28.79,43.89,58.76,74.37,80.44,17.98,15.84,39.12,26.17,63.52,30.23,13.77,2.28,27.68,3.81,42.02,5.75-1.17,.94-2.52,2.24-4.07,3.28-10.51,7.14-11.63,9.69-9.11,22.26,.55,2.81,1.62,5.51,2.67,9.01,2.55-1.21,4.41-2.04,6.21-2.97,19.4-9.97,38.64-20.3,58.33-29.72,4.77-2.3,6.44-5.35,6.97-9.56,1.06-8.46-1.42-16.59-4.53-24.21Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2 KiB |
56
index.html
Normal file
56
index.html
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<!doctype html>
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>دعوت به وینسو</title>
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||||
<link rel="stylesheet" href="css/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="screen">
|
||||
<div class="logo">
|
||||
<img src="assets/images/logo.png" alt="وینسو Winsoo" />
|
||||
</div>
|
||||
|
||||
<div class="illustration">
|
||||
<img src="assets/images/illustration.png" alt="" />
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<h1 class="title">به وینسو خوش آمدید!</h1>
|
||||
<p class="subtitle">
|
||||
دوستتان شما را به وینسو دعوت کرده است.
شماره موبایل خود را وارد کنید و
|
||||
پس از نصب و ورود با همین شماره، <strong>۲۵,۰۰۰</strong> تومان تومان
|
||||
اعتبار پاداش دریافت کنید.
|
||||
</p>
|
||||
|
||||
<div class="input-group">
|
||||
<input
|
||||
id="phoneInput"
|
||||
type="tel"
|
||||
class="phone-input"
|
||||
direction="ltr"
|
||||
inputmode="numeric"
|
||||
maxlength="11"
|
||||
placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="hint">
|
||||
برای دریافت پاداش لازم است شمارهای را وارد کنید که متعلق به خودتان
|
||||
باشد.
|
||||
</p>
|
||||
|
||||
<p id="formMessage" class="form-message" role="alert"></p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<button id="submitBtn" type="button" class="cta-btn">
|
||||
ثبت شماره و دانلود برنامه
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<script src="js/script.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
172
js/script.js
Normal file
172
js/script.js
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
// NOTE: staging credentials. Calling an OAuth2 client_credentials flow from
|
||||
// the browser exposes CLIENT_SECRET to anyone who views the page source.
|
||||
const AUTH_CONFIG = {
|
||||
CLIENT_ID: "4INGOCMoulE0fNY1SQlTbPtsWqqxGj2DdqjADq6u",
|
||||
CLIENT_SECRET:
|
||||
"KPc4dMSztNwAIB3vii3geXrzC1mKUIsAztz3t2ylC3HlrgJudJWhdrtoY6XeRJIuadTAREYYsXk9XtFHUDfPVcJvyfHMpNkz2VvghwrijhVprok0VYV7XrOirJ5nFUxD",
|
||||
TOKEN_URL: "https://accounts-staging.gooyal.ir/oauth2/token/",
|
||||
SCOPE: "accounts.invite:submit",
|
||||
};
|
||||
|
||||
const INVITE_URL = "https://accounts-staging.gooyal.ir/users/api/invite_requests/";
|
||||
|
||||
const phoneInput = document.getElementById("phoneInput");
|
||||
const submitBtn = document.getElementById("submitBtn");
|
||||
const formMessage = document.getElementById("formMessage");
|
||||
|
||||
let accessToken = null;
|
||||
let tokenRequest = null;
|
||||
|
||||
function setMessage(text, type) {
|
||||
formMessage.textContent = text || "";
|
||||
formMessage.classList.remove("error", "success");
|
||||
if (type) formMessage.classList.add(type);
|
||||
}
|
||||
|
||||
function setButtonLoading(isLoading, label) {
|
||||
submitBtn.disabled = isLoading;
|
||||
submitBtn.textContent = label || "ثبت شماره و دانلود برنامه";
|
||||
}
|
||||
|
||||
async function fetchAccessToken() {
|
||||
const basicAuth = btoa(`${AUTH_CONFIG.CLIENT_ID}:${AUTH_CONFIG.CLIENT_SECRET}`);
|
||||
|
||||
const response = await fetch(AUTH_CONFIG.TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Basic ${basicAuth}`,
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "client_credentials",
|
||||
scope: AUTH_CONFIG.SCOPE,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Token request failed (${response.status})`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
accessToken = data.access_token;
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
// Reuses an in-flight request so concurrent callers don't fire duplicate token requests.
|
||||
function ensureAccessToken() {
|
||||
if (accessToken) return Promise.resolve(accessToken);
|
||||
if (!tokenRequest) {
|
||||
tokenRequest = fetchAccessToken().finally(() => {
|
||||
tokenRequest = null;
|
||||
});
|
||||
}
|
||||
return tokenRequest;
|
||||
}
|
||||
|
||||
function getInviteCode() {
|
||||
return new URLSearchParams(window.location.search).get("inviteCode");
|
||||
}
|
||||
|
||||
const PERSIAN_DIGITS = "۰۱۲۳۴۵۶۷۸۹";
|
||||
const ARABIC_INDIC_DIGITS = "٠١٢٣٤٥٦٧٨٩";
|
||||
|
||||
// Converts Persian (۰-۹) and Arabic-Indic (٠-٩) numerals to Latin digits.
|
||||
function toLatinDigits(str) {
|
||||
return str.replace(/[۰-۹٠-٩]/g, (char) => {
|
||||
const persianIndex = PERSIAN_DIGITS.indexOf(char);
|
||||
if (persianIndex !== -1) return String(persianIndex);
|
||||
return String(ARABIC_INDIC_DIGITS.indexOf(char));
|
||||
});
|
||||
}
|
||||
|
||||
// Normalizes Iranian mobile numbers (09xxxxxxxxx, 9xxxxxxxxx, 0098/98/+98 prefixed) to E.164.
|
||||
function toE164(raw) {
|
||||
let cleaned = toLatinDigits(raw.trim()).replace(/[\s-]/g, "");
|
||||
|
||||
if (cleaned.startsWith("+98")) cleaned = cleaned.slice(3);
|
||||
else if (cleaned.startsWith("0098")) cleaned = cleaned.slice(4);
|
||||
else if (cleaned.startsWith("98")) cleaned = cleaned.slice(2);
|
||||
else if (cleaned.startsWith("0")) cleaned = cleaned.slice(1);
|
||||
|
||||
if (!/^9\d{9}$/.test(cleaned)) return null;
|
||||
return `+98${cleaned}`;
|
||||
}
|
||||
|
||||
async function submitInviteRequest(inviteCode, mobileNumber, token) {
|
||||
const response = await fetch(INVITE_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
invite_code: inviteCode,
|
||||
mobile_number: mobileNumber,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let body = null;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch (_) {
|
||||
// no JSON body
|
||||
}
|
||||
const error = new Error(body?.status_message || `Invite request failed (${response.status})`);
|
||||
error.status = response.status;
|
||||
error.body = body;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
phoneInput.classList.remove("invalid");
|
||||
setMessage("", null);
|
||||
|
||||
const inviteCode = getInviteCode();
|
||||
if (!inviteCode) {
|
||||
setMessage("لینک دعوت نامعتبر است.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const mobileNumber = toE164(phoneInput.value);
|
||||
if (!mobileNumber) {
|
||||
phoneInput.classList.add("invalid");
|
||||
setMessage("شماره موبایل را بهدرستی وارد کنید.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
setButtonLoading(true, "در حال ارسال...");
|
||||
|
||||
try {
|
||||
const token = await ensureAccessToken();
|
||||
|
||||
try {
|
||||
await submitInviteRequest(inviteCode, mobileNumber, token);
|
||||
} catch (err) {
|
||||
// Token may have expired between page load and submit — retry once with a fresh one.
|
||||
if (err.status === 401) {
|
||||
accessToken = null;
|
||||
const freshToken = await ensureAccessToken();
|
||||
await submitInviteRequest(inviteCode, mobileNumber, freshToken);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
setMessage("ثبت شماره با موفقیت انجام شد.", "success");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setMessage(err.message || "خطا در ارسال اطلاعات. لطفاً دوباره تلاش کنید.", "error");
|
||||
} finally {
|
||||
setButtonLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
submitBtn.addEventListener("click", handleSubmit);
|
||||
|
||||
ensureAccessToken().catch((err) => {
|
||||
console.error("Failed to fetch access token on load:", err);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue