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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
c2552af079
commit
66f92b634e
+211
@@ -0,0 +1,211 @@
|
||||
// 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user