Files
sanihome/src/lib/auth.ts
T
amirhosein.ashourlooandClaude Sonnet 5 66f92b634e Add customer auth, real cart/checkout, store-items pages, comments, contact form
- 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>
2026-09-06 08:06:30 +03:30

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 };
}