Initial commit: Hoomat storefront with Meshkee API and static-image slots.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-04 15:00:41 +03:30
co-authored by Cursor
commit cfabf3e327
95 changed files with 12651 additions and 0 deletions
+169
View File
@@ -0,0 +1,169 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import {
getMe,
getTenant,
refreshTokens,
} from "@/lib/meshkee/api";
import {
ACCESS_TOKEN_KEY,
REFRESH_TOKEN_KEY,
} from "@/lib/meshkee/config";
import type { AuthUser } from "@/lib/meshkee/types";
type AuthContextValue = {
user: AuthUser | null;
isAuthenticated: boolean;
ready: boolean;
businessId: string | null;
isWebsiteAdmin: boolean;
getAccessToken: () => string | null;
logout: () => void;
refreshUser: () => Promise<void>;
};
const AuthContext = createContext<AuthContextValue | null>(null);
function readToken(key: string) {
if (typeof window === "undefined") return null;
return localStorage.getItem(key);
}
function writeTokens(accessToken: string, refreshToken: string) {
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
}
function clearTokens() {
localStorage.removeItem(ACCESS_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const [ready, setReady] = useState(false);
const [businessId, setBusinessId] = useState<string | null>(null);
const logout = useCallback(() => {
clearTokens();
setUser(null);
}, []);
const getAccessToken = useCallback(() => readToken(ACCESS_TOKEN_KEY), []);
const refreshUser = useCallback(async () => {
let accessToken = readToken(ACCESS_TOKEN_KEY);
const refreshToken = readToken(REFRESH_TOKEN_KEY);
if (!accessToken && !refreshToken) {
setUser(null);
return;
}
try {
if (!accessToken && refreshToken) {
const tokens = await refreshTokens(refreshToken);
writeTokens(tokens.accessToken, tokens.refreshToken);
accessToken = tokens.accessToken;
}
if (!accessToken) {
setUser(null);
return;
}
const me = await getMe(accessToken);
setUser(me);
} catch {
if (refreshToken) {
try {
const tokens = await refreshTokens(refreshToken);
writeTokens(tokens.accessToken, tokens.refreshToken);
const me = await getMe(tokens.accessToken);
setUser(me);
return;
} catch {
clearTokens();
setUser(null);
}
} else {
clearTokens();
setUser(null);
}
}
}, []);
useEffect(() => {
let cancelled = false;
(async () => {
try {
try {
const tenant = await getTenant();
if (!cancelled) setBusinessId(String(tenant.id));
} catch {
if (!cancelled) setBusinessId(null);
}
await refreshUser();
} catch {
if (!cancelled) {
clearTokens();
setUser(null);
}
} finally {
if (!cancelled) setReady(true);
}
})().catch(() => {
// swallow unexpected bootstrap rejections (e.g. aborted HMR)
});
return () => {
cancelled = true;
};
}, [refreshUser]);
const isWebsiteAdmin = useMemo(() => {
if (!user || !businessId) return false;
if (user.isSuperAdmin || user.roles?.includes("super_admin")) return true;
return Boolean(
user.businesses?.some(
(biz) =>
String(biz.id) === String(businessId) &&
(biz.isOwner || Boolean(biz.teamRole)),
),
);
}, [user, businessId]);
const value = useMemo<AuthContextValue>(
() => ({
user,
isAuthenticated: Boolean(user),
ready,
businessId,
isWebsiteAdmin,
getAccessToken,
logout,
refreshUser,
}),
[
user,
ready,
businessId,
isWebsiteAdmin,
getAccessToken,
logout,
refreshUser,
],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}
+243
View File
@@ -0,0 +1,243 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import {
addCartItem,
getCart,
removeCartItem,
updateCartItem,
} from "@/lib/meshkee/api";
import {
cartFromGuest,
emptyCart,
normalizeCartResponse,
} from "@/lib/meshkee/cart";
import {
clearGuestCart,
readGuestCart,
writeGuestCart,
} from "@/lib/meshkee/guest-cart";
import type { CartState, GuestCartLine } from "@/lib/meshkee/types";
import { useAuth } from "@/app/providers/auth-provider";
import { useToast } from "@/app/providers/toast-provider";
type CartContextValue = {
cart: CartState;
itemCount: number;
ready: boolean;
loading: boolean;
open: boolean;
setOpen: (open: boolean) => void;
refreshCart: () => Promise<void>;
addItem: (item: {
storeItemVariantId: string;
name: string;
price: number;
quantity?: number;
image?: string | null;
slug?: string;
}) => Promise<void>;
updateQuantity: (itemId: string, quantity: number) => Promise<void>;
removeItem: (itemId: string) => Promise<void>;
guestItems: GuestCartLine[];
};
const CartContext = createContext<CartContextValue | null>(null);
export function CartProvider({ children }: { children: React.ReactNode }) {
const { ready: authReady, isAuthenticated, businessId, getAccessToken } =
useAuth();
const { showToast } = useToast();
const [cart, setCart] = useState<CartState>(emptyCart());
const [ready, setReady] = useState(false);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [guestItems, setGuestItems] = useState<GuestCartLine[]>([]);
const refreshCart = useCallback(async () => {
if (!authReady) return;
const token = getAccessToken();
if (isAuthenticated && token && businessId) {
setLoading(true);
try {
const localGuest = readGuestCart();
if (localGuest.length > 0) {
for (const line of localGuest) {
await addCartItem(businessId, token, {
storeItemVariantId: line.id,
quantity: line.quantity,
});
}
clearGuestCart();
setGuestItems([]);
}
const payload = await getCart(businessId, token);
setCart(normalizeCartResponse(payload));
} catch {
setCart(emptyCart());
} finally {
setLoading(false);
setReady(true);
}
return;
}
const guest = readGuestCart();
setGuestItems(guest);
setCart(cartFromGuest(guest));
setReady(true);
}, [authReady, businessId, getAccessToken, isAuthenticated]);
useEffect(() => {
void refreshCart().catch(() => {
setCart(emptyCart());
setReady(true);
});
}, [refreshCart]);
const addItem = useCallback(
async (item: {
storeItemVariantId: string;
name: string;
price: number;
quantity?: number;
image?: string | null;
slug?: string;
}) => {
const quantity = Math.max(1, Math.floor(item.quantity ?? 1));
const token = getAccessToken();
if (isAuthenticated && token && businessId) {
setLoading(true);
try {
const payload = await addCartItem(businessId, token, {
storeItemVariantId: item.storeItemVariantId,
quantity,
});
setCart(normalizeCartResponse(payload));
showToast("به سبد خرید اضافه شد");
setOpen(true);
} catch (error) {
showToast(
error instanceof Error ? error.message : "افزودن به سبد خرید ناموفق بود",
);
} finally {
setLoading(false);
}
return;
}
const current = readGuestCart();
const existing = current.find((line) => line.id === item.storeItemVariantId);
const next = existing
? current.map((line) =>
line.id === item.storeItemVariantId
? { ...line, quantity: line.quantity + quantity }
: line,
)
: [
...current,
{
id: item.storeItemVariantId,
name: item.name,
price: item.price,
image: item.image ?? null,
slug: item.slug,
quantity,
},
];
writeGuestCart(next);
setGuestItems(next);
setCart(cartFromGuest(next));
showToast("به سبد خرید اضافه شد");
setOpen(true);
},
[businessId, getAccessToken, isAuthenticated, showToast],
);
const updateQuantity = useCallback(
async (itemId: string, quantity: number) => {
const token = getAccessToken();
if (isAuthenticated && token && businessId) {
setLoading(true);
try {
const payload =
quantity < 1
? await removeCartItem(businessId, token, itemId)
: await updateCartItem(businessId, token, itemId, quantity);
setCart(normalizeCartResponse(payload));
} finally {
setLoading(false);
}
return;
}
const current = readGuestCart();
const next =
quantity < 1
? current.filter((line) => line.id !== itemId)
: current.map((line) =>
line.id === itemId ? { ...line, quantity } : line,
);
writeGuestCart(next);
setGuestItems(next);
setCart(cartFromGuest(next));
},
[businessId, getAccessToken, isAuthenticated],
);
const removeItem = useCallback(
async (itemId: string) => {
await updateQuantity(itemId, 0);
},
[updateQuantity],
);
const itemCount = useMemo(
() => cart.items.reduce((sum, line) => sum + line.quantity, 0),
[cart.items],
);
const value = useMemo<CartContextValue>(
() => ({
cart,
itemCount,
ready,
loading,
open,
setOpen,
refreshCart,
addItem,
updateQuantity,
removeItem,
guestItems,
}),
[
cart,
itemCount,
ready,
loading,
open,
refreshCart,
addItem,
updateQuantity,
removeItem,
guestItems,
],
);
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
}
export function useCart() {
const ctx = useContext(CartContext);
if (!ctx) throw new Error("useCart must be used within CartProvider");
return ctx;
}
+15
View File
@@ -0,0 +1,15 @@
"use client";
import { AuthProvider } from "@/app/providers/auth-provider";
import { CartProvider } from "@/app/providers/cart-provider";
import { ToastProvider } from "@/app/providers/toast-provider";
export function AppProviders({ children }: { children: React.ReactNode }) {
return (
<ToastProvider>
<AuthProvider>
<CartProvider>{children}</CartProvider>
</AuthProvider>
</ToastProvider>
);
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
type ToastContextValue = {
message: string | null;
showToast: (message: string) => void;
clearToast: () => void;
};
const ToastContext = createContext<ToastContextValue | null>(null);
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [message, setMessage] = useState<string | null>(null);
const showToast = useCallback((next: string) => {
setMessage(next);
}, []);
const clearToast = useCallback(() => setMessage(null), []);
useEffect(() => {
if (!message) return;
const timer = window.setTimeout(() => setMessage(null), 3200);
return () => window.clearTimeout(timer);
}, [message]);
const value = useMemo(
() => ({ message, showToast, clearToast }),
[message, showToast, clearToast],
);
return (
<ToastContext.Provider value={value}>
{children}
{message ? (
<div className="toast" role="status" aria-live="polite">
{message}
</div>
) : null}
</ToastContext.Provider>
);
}
export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToast must be used within ToastProvider");
return ctx;
}