commit 0d09ee74c363c7841c694333335ba09777a381f5 Author: Afra ‌ Date: Sat Jul 25 17:08:19 2026 +0330 first commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..f6f9015 --- /dev/null +++ b/README.md @@ -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= +``` + +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. diff --git a/assets/fonts/iransans/IRANSans(FaNum).ttf b/assets/fonts/iransans/IRANSans(FaNum).ttf new file mode 100644 index 0000000..b703642 Binary files /dev/null and b/assets/fonts/iransans/IRANSans(FaNum).ttf differ diff --git a/assets/fonts/iransans/IRANSans(FaNum)_Bold.ttf b/assets/fonts/iransans/IRANSans(FaNum)_Bold.ttf new file mode 100644 index 0000000..640b340 Binary files /dev/null and b/assets/fonts/iransans/IRANSans(FaNum)_Bold.ttf differ diff --git a/assets/fonts/iransans/IRANSans(FaNum)_Light.ttf b/assets/fonts/iransans/IRANSans(FaNum)_Light.ttf new file mode 100644 index 0000000..44e4c8a Binary files /dev/null and b/assets/fonts/iransans/IRANSans(FaNum)_Light.ttf differ diff --git a/assets/fonts/iransans/IRANSans(FaNum)_Medium.ttf b/assets/fonts/iransans/IRANSans(FaNum)_Medium.ttf new file mode 100644 index 0000000..0c34cda Binary files /dev/null and b/assets/fonts/iransans/IRANSans(FaNum)_Medium.ttf differ diff --git a/assets/fonts/iransans/IRANSans(FaNum)_UltraLight.ttf b/assets/fonts/iransans/IRANSans(FaNum)_UltraLight.ttf new file mode 100644 index 0000000..d3ebe15 Binary files /dev/null and b/assets/fonts/iransans/IRANSans(FaNum)_UltraLight.ttf differ diff --git a/assets/fonts/iransans/IRANSans.ttf b/assets/fonts/iransans/IRANSans.ttf new file mode 100644 index 0000000..320211c Binary files /dev/null and b/assets/fonts/iransans/IRANSans.ttf differ diff --git a/assets/fonts/iransans/IRANSansX-Bold.ttf b/assets/fonts/iransans/IRANSansX-Bold.ttf new file mode 100644 index 0000000..1d57a9a Binary files /dev/null and b/assets/fonts/iransans/IRANSansX-Bold.ttf differ diff --git a/assets/fonts/iransans/IRANSansX-Regular.ttf b/assets/fonts/iransans/IRANSansX-Regular.ttf new file mode 100644 index 0000000..69cf084 Binary files /dev/null and b/assets/fonts/iransans/IRANSansX-Regular.ttf differ diff --git a/assets/fonts/iransans/IRANSansXFaNum-Bold.ttf b/assets/fonts/iransans/IRANSansXFaNum-Bold.ttf new file mode 100644 index 0000000..0bea395 Binary files /dev/null and b/assets/fonts/iransans/IRANSansXFaNum-Bold.ttf differ diff --git a/assets/fonts/iransans/IRANSansXFaNum-Regular.ttf b/assets/fonts/iransans/IRANSansXFaNum-Regular.ttf new file mode 100644 index 0000000..86f5b80 Binary files /dev/null and b/assets/fonts/iransans/IRANSansXFaNum-Regular.ttf differ diff --git a/assets/fonts/iransans/IRANSansXNoEn-Bold.ttf b/assets/fonts/iransans/IRANSansXNoEn-Bold.ttf new file mode 100644 index 0000000..088872c Binary files /dev/null and b/assets/fonts/iransans/IRANSansXNoEn-Bold.ttf differ diff --git a/assets/fonts/iransans/IRANSansXNoEn-Regular.ttf b/assets/fonts/iransans/IRANSansXNoEn-Regular.ttf new file mode 100644 index 0000000..aff9823 Binary files /dev/null and b/assets/fonts/iransans/IRANSansXNoEn-Regular.ttf differ diff --git a/assets/fonts/iransans/IRANSans_Black.ttf b/assets/fonts/iransans/IRANSans_Black.ttf new file mode 100644 index 0000000..5fb4340 Binary files /dev/null and b/assets/fonts/iransans/IRANSans_Black.ttf differ diff --git a/assets/fonts/iransans/IRANSans_Bold.ttf b/assets/fonts/iransans/IRANSans_Bold.ttf new file mode 100644 index 0000000..dfa64eb Binary files /dev/null and b/assets/fonts/iransans/IRANSans_Bold.ttf differ diff --git a/assets/fonts/iransans/IRANSans_Light.ttf b/assets/fonts/iransans/IRANSans_Light.ttf new file mode 100644 index 0000000..f91be8e Binary files /dev/null and b/assets/fonts/iransans/IRANSans_Light.ttf differ diff --git a/assets/fonts/iransans/IRANSans_Medium.ttf b/assets/fonts/iransans/IRANSans_Medium.ttf new file mode 100644 index 0000000..403019f Binary files /dev/null and b/assets/fonts/iransans/IRANSans_Medium.ttf differ diff --git a/assets/fonts/iransans/IRANSans_UltraLight.ttf b/assets/fonts/iransans/IRANSans_UltraLight.ttf new file mode 100644 index 0000000..8f06c90 Binary files /dev/null and b/assets/fonts/iransans/IRANSans_UltraLight.ttf differ diff --git a/assets/images/illustration.png b/assets/images/illustration.png new file mode 100644 index 0000000..040fcd5 Binary files /dev/null and b/assets/images/illustration.png differ diff --git a/assets/images/logo.png b/assets/images/logo.png new file mode 100644 index 0000000..0c34ccd Binary files /dev/null and b/assets/images/logo.png differ diff --git a/css/style.css b/css/style.css new file mode 100644 index 0000000..94e48e9 --- /dev/null +++ b/css/style.css @@ -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; +} diff --git a/favicon.svg b/favicon.svg new file mode 100644 index 0000000..8ad0cbf --- /dev/null +++ b/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..3eb6002 --- /dev/null +++ b/index.html @@ -0,0 +1,56 @@ + + + + + + دعوت به وینسو + + + + +
+ + +
+ +
+ +
+

به وینسو خوش آمدید!

+

+ دوستتان شما را به وینسو دعوت کرده است.
شماره موبایل خود را وارد کنید و + پس از نصب و ورود با همین شماره، ۲۵,۰۰۰ تومان تومان + اعتبار پاداش دریافت کنید. +

+ +
+ +
+ +

+ برای دریافت پاداش لازم است شماره‌ای را وارد کنید که متعلق به خودتان + باشد. +

+ + +
+ + +
+ + + diff --git a/js/script.js b/js/script.js new file mode 100644 index 0000000..8dc3d31 --- /dev/null +++ b/js/script.js @@ -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); +});