first commit

This commit is contained in:
Afra ‌ 2026-07-25 17:08:19 +03:30
commit 0d09ee74c3
24 changed files with 547 additions and 0 deletions

75
README.md Normal file
View 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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
assets/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

233
css/style.css Normal file
View 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
View 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
View 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
View 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);
});