- Real in-site login/register (password + SMS OTP) against Meshkee's /auth API, with self-healing access-token refresh (15 min lifetime) and an in-flight-refresh guard so AuthContext and CartContext don't race each other into a failed concurrent refresh. - Real cart: header cart button/badge, a cart popup, and a working "add to cart" on both product cards (where a specific variant id is known) and the product/store-item detail buy-box (always resolves a purchasable variant, even for single-SKU products with no selectable variation). - /checkout: address + cash/bank-transfer payment, posts to the real checkout API and creates an order; refreshes the cart afterward so the header badge doesn't go stale. - New /store-items and /store-items/[id] pages (variant-level browsing, separate from the product-level /products catalog), cross-referencing the parent product for images/description/technical-info. - Product gallery: click-to-open fullscreen lightbox alongside the existing hover-zoom. - Comments tab wired to the real comments API, gated to logged-in users (a product decision — the API itself doesn't require auth) with a login prompt otherwise. - Similar-products row on product/store-item detail pages. - Contact page wired to the real contact-submissions API, with a toast and inline confirmation on success, and the form resetting. - CONTEXT.md: a maintained architecture/conventions doc for this repo, now auto-loaded via CLAUDE.md, covering the Meshkee integration quirks and UI conventions established across this project. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
212 lines
7.4 KiB
TypeScript
212 lines
7.4 KiB
TypeScript
// Client-side session storage for Meshkee customer auth. Tokens live in
|
|
// localStorage (per-browser, never sent anywhere but api.meshkee.com).
|
|
const ACCESS_KEY = "sanihome_access_token";
|
|
const REFRESH_KEY = "sanihome_refresh_token";
|
|
|
|
export type AuthUser = {
|
|
id: string;
|
|
firstName?: string;
|
|
lastName?: string;
|
|
cellNumber?: string;
|
|
email?: string;
|
|
};
|
|
|
|
export function getStoredTokens(): { accessToken: string; refreshToken: string | null } | null {
|
|
if (typeof window === "undefined") return null;
|
|
try {
|
|
const accessToken = window.localStorage.getItem(ACCESS_KEY);
|
|
if (!accessToken) return null;
|
|
return { accessToken, refreshToken: window.localStorage.getItem(REFRESH_KEY) };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function storeTokens(accessToken: string, refreshToken?: string | null): void {
|
|
if (typeof window === "undefined") return;
|
|
try {
|
|
window.localStorage.setItem(ACCESS_KEY, accessToken);
|
|
if (refreshToken) window.localStorage.setItem(REFRESH_KEY, refreshToken);
|
|
} catch {
|
|
// Private browsing / storage disabled — session just won't persist.
|
|
}
|
|
}
|
|
|
|
// Access tokens are short-lived (~15 min). Call this whenever a request
|
|
// comes back 401 so the shopper doesn't get silently logged out mid-session.
|
|
// Returns the fresh access token, or null if the refresh token is also gone/invalid
|
|
// (caller should then clear the session and prompt login again).
|
|
//
|
|
// AuthContext and CartContext each independently notice an expired token and
|
|
// call this — without coordination they'd fire two concurrent /auth/refresh
|
|
// requests. If the backend rotates the refresh token on use, the second
|
|
// request then fails (observed as a 500/400). This in-flight guard makes
|
|
// every concurrent caller share the same single request instead of racing.
|
|
let inFlightRefresh: Promise<string | null> | null = null;
|
|
|
|
export async function refreshAccessToken(): Promise<string | null> {
|
|
if (inFlightRefresh) return inFlightRefresh;
|
|
|
|
inFlightRefresh = (async () => {
|
|
const stored = getStoredTokens();
|
|
if (!stored?.refreshToken) return null;
|
|
try {
|
|
const res = await fetch(`${AUTH_API_BASE}/auth/refresh`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ refreshToken: stored.refreshToken }),
|
|
});
|
|
if (!res.ok) {
|
|
clearTokens();
|
|
return null;
|
|
}
|
|
const data = (await res.json()) as RawAuthResponse;
|
|
const accessToken = data.accessToken ?? data.token ?? data.access_token;
|
|
if (!accessToken) return null;
|
|
storeTokens(accessToken, data.refreshToken ?? data.refresh_token ?? stored.refreshToken);
|
|
return accessToken;
|
|
} catch {
|
|
return null;
|
|
}
|
|
})();
|
|
|
|
try {
|
|
return await inFlightRefresh;
|
|
} finally {
|
|
inFlightRefresh = null;
|
|
}
|
|
}
|
|
|
|
export function clearTokens(): void {
|
|
if (typeof window === "undefined") return;
|
|
try {
|
|
window.localStorage.removeItem(ACCESS_KEY);
|
|
window.localStorage.removeItem(REFRESH_KEY);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
const AUTH_API_BASE = "https://api.meshkee.com/api/v1";
|
|
export const WEBSITE_DOMAIN = "sanihome.ir";
|
|
|
|
async function authFetch<T>(
|
|
path: string,
|
|
options: { method?: string; body?: unknown; token?: string } = {},
|
|
): Promise<{ ok: true; data: T } | { ok: false; status: number; message: string }> {
|
|
try {
|
|
const res = await fetch(`${AUTH_API_BASE}${path}`, {
|
|
method: options.method ?? "GET",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
|
|
},
|
|
body: options.body != null ? JSON.stringify(options.body) : undefined,
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
return {
|
|
ok: false,
|
|
status: res.status,
|
|
message: (data as { message?: string })?.message ?? "خطایی رخ داد. دوباره تلاش کنید.",
|
|
};
|
|
}
|
|
return { ok: true, data: data as T };
|
|
} catch {
|
|
return { ok: false, status: 0, message: "ارتباط با سرور برقرار نشد." };
|
|
}
|
|
}
|
|
|
|
// The API doesn't document the exact success-response field names for
|
|
// login/register/login-otp — accessToken/refreshToken/user is the standard
|
|
// shape (confirmed accessToken+refreshToken naming via /auth/refresh's own
|
|
// request body, and a `user` wrapper via /auth/me's documented description).
|
|
// Read defensively in case a field is nested or named slightly differently.
|
|
type RawAuthResponse = {
|
|
accessToken?: string;
|
|
token?: string;
|
|
access_token?: string;
|
|
refreshToken?: string;
|
|
refresh_token?: string;
|
|
user?: Record<string, unknown>;
|
|
customer?: Record<string, unknown>;
|
|
};
|
|
|
|
function extractSession(raw: RawAuthResponse): { accessToken: string; refreshToken: string | null; user: AuthUser } | null {
|
|
const accessToken = raw.accessToken ?? raw.token ?? raw.access_token;
|
|
if (!accessToken) return null;
|
|
const refreshToken = raw.refreshToken ?? raw.refresh_token ?? null;
|
|
const rawUser = raw.user ?? raw.customer ?? {};
|
|
return {
|
|
accessToken,
|
|
refreshToken,
|
|
user: {
|
|
id: String(rawUser.id ?? ""),
|
|
firstName: rawUser.firstName as string | undefined,
|
|
lastName: rawUser.lastName as string | undefined,
|
|
cellNumber: rawUser.cellNumber as string | undefined,
|
|
email: rawUser.email as string | undefined,
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function loginWithPassword(cellNumber: string, password: string) {
|
|
const res = await authFetch<RawAuthResponse>("/auth/login", {
|
|
method: "POST",
|
|
body: { cellNumber, password },
|
|
});
|
|
if (!res.ok) return res;
|
|
const session = extractSession(res.data);
|
|
if (!session) return { ok: false as const, status: 200, message: "پاسخ سرور نامعتبر بود." };
|
|
return { ok: true as const, data: session };
|
|
}
|
|
|
|
export async function loginWithOtp(cellNumber: string, code: string) {
|
|
const res = await authFetch<RawAuthResponse>("/auth/login-otp", {
|
|
method: "POST",
|
|
body: { cellNumber, code },
|
|
});
|
|
if (!res.ok) return res;
|
|
const session = extractSession(res.data);
|
|
if (!session) return { ok: false as const, status: 200, message: "پاسخ سرور نامعتبر بود." };
|
|
return { ok: true as const, data: session };
|
|
}
|
|
|
|
export async function sendOtp(cellNumber: string) {
|
|
return authFetch<{ message?: string }>("/auth/send-otp", {
|
|
method: "POST",
|
|
body: { cellNumber, domain: WEBSITE_DOMAIN },
|
|
});
|
|
}
|
|
|
|
export async function register(input: {
|
|
cellNumber: string;
|
|
password: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
email?: string;
|
|
}) {
|
|
const res = await authFetch<RawAuthResponse>("/auth/register", {
|
|
method: "POST",
|
|
body: { ...input, domain: WEBSITE_DOMAIN },
|
|
});
|
|
if (!res.ok) return res;
|
|
const session = extractSession(res.data);
|
|
if (!session) return { ok: false as const, status: 200, message: "پاسخ سرور نامعتبر بود." };
|
|
return { ok: true as const, data: session };
|
|
}
|
|
|
|
export async function fetchMe(accessToken: string) {
|
|
const res = await authFetch<{ user?: Record<string, unknown> }>("/auth/me", { token: accessToken });
|
|
if (!res.ok) return res;
|
|
const rawUser = res.data.user ?? {};
|
|
const user: AuthUser = {
|
|
id: String(rawUser.id ?? ""),
|
|
firstName: rawUser.firstName as string | undefined,
|
|
lastName: rawUser.lastName as string | undefined,
|
|
cellNumber: rawUser.cellNumber as string | undefined,
|
|
email: rawUser.email as string | undefined,
|
|
};
|
|
return { ok: true as const, data: user };
|
|
}
|