Replaces placeholder colors/type-scale with the documented Figma design system (§1), and rebuilds login as three screens matching the doc exactly: role selection, phone entry, and 6-box OTP verification with countdown resend. Fixes a real bug along the way: normalizeIranianPhone stripped Persian-digit keyboard input entirely instead of converting it (\D matches non-ASCII digits too) -- added toLatinDigits/toPersianDigits and verified the round-trip in isolation.
98 lines
3.3 KiB
TypeScript
98 lines
3.3 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* Browser-side calls straight to Gooyal accounts (CORS is open there).
|
|
* The client_id/secret are intentionally public here — see .env.local comment.
|
|
*/
|
|
|
|
import { toLatinDigits } from "@/lib/format/persian-digits";
|
|
|
|
const ACCOUNTS_BASE_URL = process.env.NEXT_PUBLIC_GOOYAL_ACCOUNTS_BASE_URL!;
|
|
const CLIENT_ID = process.env.NEXT_PUBLIC_GOOYAL_CLIENT_ID!;
|
|
const CLIENT_SECRET = process.env.NEXT_PUBLIC_GOOYAL_CLIENT_SECRET!;
|
|
const SCOPE = process.env.NEXT_PUBLIC_GOOYAL_OAUTH_SCOPE!;
|
|
|
|
function basicAuthHeader() {
|
|
return "Basic " + btoa(`${CLIENT_ID}:${CLIENT_SECRET}`);
|
|
}
|
|
|
|
const ERROR_MESSAGES_FA: Record<string, string> = {
|
|
invalid_grant: "کد تایید نامعتبر یا منقضی شده است.",
|
|
invalid_client: "خطا در پیکربندی سرویس احراز هویت. لطفاً بعداً تلاش کنید.",
|
|
invalid_request: "درخواست نامعتبر است.",
|
|
};
|
|
|
|
export class GooyalClientError extends Error {
|
|
constructor(
|
|
public status: number,
|
|
public code: string,
|
|
description?: string,
|
|
) {
|
|
super(ERROR_MESSAGES_FA[code] ?? description ?? code);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Accepts local Iranian input (09..., 9..., 0098...) — including Persian-digit
|
|
* keyboard input — and normalizes to Gooyal's required +989999999999.
|
|
*/
|
|
export function normalizeIranianPhone(input: string): string {
|
|
const digits = toLatinDigits(input).replace(/\D/g, "");
|
|
const local = digits.replace(/^0098/, "").replace(/^98/, "").replace(/^0/, "");
|
|
return `+98${local}`;
|
|
}
|
|
|
|
function extractWrappedErrorMessage(data: unknown): string | undefined {
|
|
const details = (data as { details?: { message?: string | Record<string, string[]> } })?.details;
|
|
if (!details) return undefined;
|
|
if (typeof details.message === "string") return details.message;
|
|
if (details.message && typeof details.message === "object") {
|
|
const first = Object.values(details.message)[0];
|
|
return Array.isArray(first) ? first[0] : undefined;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export async function requestOtp(phoneNumber: string) {
|
|
const res = await fetch(`${ACCOUNTS_BASE_URL}/users/api/request_otp/`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ phone_number: phoneNumber }),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
throw new GooyalClientError(res.status, "request_otp_failed", extractWrappedErrorMessage(data));
|
|
}
|
|
return data as { phone_number: string; otp_expire: string; ttl: number };
|
|
}
|
|
|
|
export interface GooyalTokenResponse {
|
|
access_token: string;
|
|
refresh_token?: string;
|
|
token_type: string;
|
|
expires_in: number;
|
|
scope: string;
|
|
}
|
|
|
|
export async function exchangeOtpForToken(phoneNumber: string, otp: string) {
|
|
const body = new URLSearchParams({
|
|
grant_type: "password",
|
|
username: phoneNumber,
|
|
password: otp,
|
|
scope: SCOPE,
|
|
auth_fields: "phone_number:otp",
|
|
});
|
|
const res = await fetch(`${ACCOUNTS_BASE_URL}/oauth2/token/`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: basicAuthHeader(),
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
},
|
|
body: body.toString(),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
throw new GooyalClientError(res.status, data.error ?? "token_exchange_failed", data.error_description);
|
|
}
|
|
return data as GooyalTokenResponse;
|
|
}
|