5134 lines
184 KiB
TypeScript
5134 lines
184 KiB
TypeScript
import {
|
||
Switch,
|
||
Route,
|
||
Router as WouterRouter,
|
||
Link,
|
||
useLocation,
|
||
} from "wouter";
|
||
import AdminPage from "./pages/Admin";
|
||
import {
|
||
QueryClient,
|
||
QueryClientProvider,
|
||
useQuery,
|
||
} from "@tanstack/react-query";
|
||
import {
|
||
useState,
|
||
useRef,
|
||
useEffect,
|
||
createContext,
|
||
useContext,
|
||
useCallback,
|
||
} from "react";
|
||
import { motion, AnimatePresence } from "framer-motion";
|
||
import { API } from "./lib/api";
|
||
import {
|
||
isJsonResponse,
|
||
loginPreviewStoreUser,
|
||
registerPreviewStoreUser,
|
||
} from "./lib/mock-auth";
|
||
import { translations, Lang } from "./lib/i18n";
|
||
import {
|
||
FEATURED_MENU_ITEMS,
|
||
FOOTER_POLICY_LINKS,
|
||
FALLBACK_STORE_SETTINGS,
|
||
getFallbackCategories,
|
||
getFallbackCategoryTree,
|
||
getFallbackProduct,
|
||
getFallbackProducts,
|
||
} from "./lib/store-fallback";
|
||
import { installPreviewAdminApi } from "./lib/admin-preview-api";
|
||
|
||
if (typeof window !== "undefined") installPreviewAdminApi();
|
||
|
||
// ─── Language Context ─────────────────────────────────
|
||
interface LangCtx {
|
||
lang: Lang;
|
||
setLang: (l: Lang) => void;
|
||
t: (key: keyof typeof translations.ar) => string;
|
||
dir: "rtl" | "ltr";
|
||
}
|
||
const LanguageContext = createContext<LangCtx>({
|
||
lang: "ar",
|
||
setLang: () => {},
|
||
t: (k) => translations.ar[k],
|
||
dir: "rtl",
|
||
});
|
||
function useLang() {
|
||
return useContext(LanguageContext);
|
||
}
|
||
|
||
function LanguageProvider({ children }: { children: React.ReactNode }) {
|
||
const [lang, setLangState] = useState<Lang>(
|
||
() => (localStorage.getItem("extra_lang") as Lang) ?? "ar",
|
||
);
|
||
const setLang = (l: Lang) => {
|
||
setLangState(l);
|
||
localStorage.setItem("extra_lang", l);
|
||
};
|
||
const t = useCallback(
|
||
(key: keyof typeof translations.ar): string =>
|
||
translations[lang][key] ?? translations.ar[key],
|
||
[lang],
|
||
);
|
||
const dir: "rtl" | "ltr" = lang === "ar" ? "rtl" : "ltr";
|
||
useEffect(() => {
|
||
document.documentElement.dir = dir;
|
||
document.documentElement.lang = lang;
|
||
}, [dir, lang]);
|
||
return (
|
||
<LanguageContext.Provider value={{ lang, setLang, t, dir }}>
|
||
{children}
|
||
</LanguageContext.Provider>
|
||
);
|
||
}
|
||
|
||
function proxyImg(url: string): string {
|
||
if (!url) return "";
|
||
if (
|
||
/^https?:\/\//i.test(url) ||
|
||
/^data:image\//i.test(url) ||
|
||
/^blob:/i.test(url)
|
||
)
|
||
return url;
|
||
return `${API}/image-proxy?url=${encodeURIComponent(url)}`;
|
||
}
|
||
|
||
async function fetchJsonOrFallback<T>(
|
||
url: string,
|
||
fallback: () => T | Promise<T>,
|
||
): Promise<T> {
|
||
try {
|
||
const res = await fetch(url);
|
||
const contentType = res.headers.get("content-type") || "";
|
||
if (!res.ok || !contentType.includes("application/json"))
|
||
throw new Error("API unavailable");
|
||
return await res.json();
|
||
} catch {
|
||
return await fallback();
|
||
}
|
||
}
|
||
|
||
const queryClient = new QueryClient({
|
||
defaultOptions: { queries: { staleTime: 60_000 } },
|
||
});
|
||
|
||
// ─── Cart Context ────────────────────────────────────
|
||
interface CartItem {
|
||
product: Product;
|
||
quantity: number;
|
||
color: string | null;
|
||
size: string | null;
|
||
}
|
||
interface CartCtx {
|
||
items: CartItem[];
|
||
addItem: (
|
||
product: Product,
|
||
qty: number,
|
||
color: string | null,
|
||
size: string | null,
|
||
) => void;
|
||
removeItem: (
|
||
productId: number,
|
||
color: string | null,
|
||
size: string | null,
|
||
) => void;
|
||
updateQty: (
|
||
productId: number,
|
||
color: string | null,
|
||
size: string | null,
|
||
qty: number,
|
||
) => void;
|
||
clearCart: () => void;
|
||
count: number;
|
||
subtotal: number;
|
||
}
|
||
const CartContext = createContext<CartCtx>({
|
||
items: [],
|
||
addItem: () => {},
|
||
removeItem: () => {},
|
||
updateQty: () => {},
|
||
clearCart: () => {},
|
||
count: 0,
|
||
subtotal: 0,
|
||
});
|
||
function useCart() {
|
||
return useContext(CartContext);
|
||
}
|
||
|
||
function CartProvider({ children }: { children: React.ReactNode }) {
|
||
const [items, setItems] = useState<CartItem[]>(() => {
|
||
try {
|
||
return JSON.parse(localStorage.getItem("extra_cart") || "[]");
|
||
} catch {
|
||
return [];
|
||
}
|
||
});
|
||
|
||
const saveItems = useCallback((next: CartItem[]) => {
|
||
setItems(next);
|
||
localStorage.setItem("extra_cart", JSON.stringify(next));
|
||
}, []);
|
||
|
||
const key = (id: number, color: string | null, size: string | null) =>
|
||
`${id}|${color}|${size}`;
|
||
|
||
const addItem = useCallback(
|
||
(
|
||
product: Product,
|
||
qty: number,
|
||
color: string | null,
|
||
size: string | null,
|
||
) => {
|
||
setItems((prev) => {
|
||
const k = key(product.id, color, size);
|
||
const exists = prev.find(
|
||
(i) => key(i.product.id, i.color, i.size) === k,
|
||
);
|
||
const next = exists
|
||
? prev.map((i) =>
|
||
key(i.product.id, i.color, i.size) === k
|
||
? {
|
||
...i,
|
||
quantity: Math.min(i.quantity + qty, i.product.stock),
|
||
}
|
||
: i,
|
||
)
|
||
: [...prev, { product, quantity: qty, color, size }];
|
||
localStorage.setItem("extra_cart", JSON.stringify(next));
|
||
return next;
|
||
});
|
||
},
|
||
[],
|
||
);
|
||
|
||
const removeItem = useCallback(
|
||
(productId: number, color: string | null, size: string | null) => {
|
||
saveItems(
|
||
items.filter(
|
||
(i) =>
|
||
key(i.product.id, i.color, i.size) !== key(productId, color, size),
|
||
),
|
||
);
|
||
},
|
||
[items, saveItems],
|
||
);
|
||
|
||
const updateQty = useCallback(
|
||
(
|
||
productId: number,
|
||
color: string | null,
|
||
size: string | null,
|
||
qty: number,
|
||
) => {
|
||
if (qty < 1) {
|
||
removeItem(productId, color, size);
|
||
return;
|
||
}
|
||
saveItems(
|
||
items.map((i) =>
|
||
key(i.product.id, i.color, i.size) === key(productId, color, size)
|
||
? { ...i, quantity: qty }
|
||
: i,
|
||
),
|
||
);
|
||
},
|
||
[items, saveItems, removeItem],
|
||
);
|
||
|
||
const clearCart = useCallback(() => saveItems([]), [saveItems]);
|
||
|
||
const count = items.reduce((s, i) => s + i.quantity, 0);
|
||
const subtotal = items.reduce(
|
||
(s, i) => s + parseFloat(i.product.price) * i.quantity,
|
||
0,
|
||
);
|
||
|
||
return (
|
||
<CartContext.Provider
|
||
value={{
|
||
items,
|
||
addItem,
|
||
removeItem,
|
||
updateQty,
|
||
clearCart,
|
||
count,
|
||
subtotal,
|
||
}}
|
||
>
|
||
{children}
|
||
</CartContext.Provider>
|
||
);
|
||
}
|
||
|
||
// ─── Auth Context ────────────────────────────────────
|
||
interface AuthUser {
|
||
id: number;
|
||
name: string | null;
|
||
email: string;
|
||
}
|
||
interface AuthCtx {
|
||
user: AuthUser | null;
|
||
token: string | null;
|
||
login: (user: AuthUser, token: string) => void;
|
||
logout: () => void;
|
||
openAuth: (mode?: "login" | "register") => void;
|
||
}
|
||
const AuthContext = createContext<AuthCtx>({
|
||
user: null,
|
||
token: null,
|
||
login: () => {},
|
||
logout: () => {},
|
||
openAuth: () => {},
|
||
});
|
||
function useAuth() {
|
||
return useContext(AuthContext);
|
||
}
|
||
|
||
function AuthProvider({ children }: { children: React.ReactNode }) {
|
||
const [user, setUser] = useState<AuthUser | null>(() => {
|
||
try {
|
||
const u = localStorage.getItem("extra_user");
|
||
return u ? JSON.parse(u) : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
});
|
||
const [token, setToken] = useState<string | null>(() =>
|
||
localStorage.getItem("extra_token"),
|
||
);
|
||
const [authOpen, setAuthOpen] = useState(false);
|
||
const [authMode, setAuthMode] = useState<"login" | "register">("login");
|
||
|
||
const login = useCallback((u: AuthUser, t: string) => {
|
||
setUser(u);
|
||
setToken(t);
|
||
localStorage.setItem("extra_user", JSON.stringify(u));
|
||
localStorage.setItem("extra_token", t);
|
||
setAuthOpen(false);
|
||
}, []);
|
||
|
||
const logout = useCallback(() => {
|
||
setUser(null);
|
||
setToken(null);
|
||
localStorage.removeItem("extra_user");
|
||
localStorage.removeItem("extra_token");
|
||
}, []);
|
||
|
||
const openAuth = useCallback((mode: "login" | "register" = "login") => {
|
||
setAuthMode(mode);
|
||
setAuthOpen(true);
|
||
}, []);
|
||
|
||
return (
|
||
<AuthContext.Provider value={{ user, token, login, logout, openAuth }}>
|
||
{children}
|
||
<AuthDrawer
|
||
open={authOpen}
|
||
mode={authMode}
|
||
setMode={setAuthMode}
|
||
onClose={() => setAuthOpen(false)}
|
||
/>
|
||
</AuthContext.Provider>
|
||
);
|
||
}
|
||
|
||
function AuthDrawer({
|
||
open,
|
||
mode,
|
||
setMode,
|
||
onClose,
|
||
}: {
|
||
open: boolean;
|
||
mode: "login" | "register";
|
||
setMode: (m: "login" | "register") => void;
|
||
onClose: () => void;
|
||
}) {
|
||
const { login } = useAuth();
|
||
const showToast = useShowToast();
|
||
const { t, dir } = useLang();
|
||
const [form, setForm] = useState({
|
||
name: "",
|
||
email: "",
|
||
password: "",
|
||
confirm: "",
|
||
remember: false,
|
||
});
|
||
const [loading, setLoading] = useState(false);
|
||
const [showPass, setShowPass] = useState(false);
|
||
const set = (k: string, v: string | boolean) =>
|
||
setForm((f) => ({ ...f, [k]: v }));
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
setLoading(true);
|
||
try {
|
||
const endpoint = mode === "login" ? "/auth/login" : "/auth/register";
|
||
const body =
|
||
mode === "login"
|
||
? {
|
||
email: form.email,
|
||
password: form.password,
|
||
remember_me: form.remember,
|
||
}
|
||
: {
|
||
name: form.name,
|
||
email: form.email,
|
||
password: form.password,
|
||
confirm_password: form.confirm,
|
||
};
|
||
|
||
let data: { user: AuthUser; token: string };
|
||
|
||
try {
|
||
const res = await fetch(`${API}${endpoint}`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(body),
|
||
});
|
||
|
||
if (!isJsonResponse(res)) throw new Error("preview-api-unavailable");
|
||
|
||
const json = await res.json();
|
||
if (!res.ok) {
|
||
showToast(json.error || "حدث خطأ", "error");
|
||
return;
|
||
}
|
||
data = json;
|
||
} catch (error) {
|
||
if (
|
||
error instanceof Error &&
|
||
error.message !== "preview-api-unavailable"
|
||
)
|
||
throw error;
|
||
data =
|
||
mode === "login"
|
||
? loginPreviewStoreUser({
|
||
email: form.email,
|
||
password: form.password,
|
||
})
|
||
: registerPreviewStoreUser({
|
||
name: form.name,
|
||
email: form.email,
|
||
password: form.password,
|
||
confirm_password: form.confirm,
|
||
});
|
||
showToast(
|
||
"تم استخدام وضع المعاينة لتسجيل الدخول لأن خدمة API غير متاحة حالياً",
|
||
"success",
|
||
);
|
||
}
|
||
|
||
login(data.user, data.token);
|
||
showToast(
|
||
mode === "login"
|
||
? `${t("welcome_back")} ${data.user.name || data.user.email} 👋`
|
||
: t("account_created"),
|
||
"success",
|
||
);
|
||
} catch (error) {
|
||
showToast(
|
||
error instanceof Error ? error.message : t("server_error"),
|
||
"error",
|
||
);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<AnimatePresence>
|
||
{open && (
|
||
<>
|
||
<motion.div
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
className="fixed inset-0 bg-black/70 z-[200] backdrop-blur-sm"
|
||
onClick={onClose}
|
||
/>
|
||
<motion.div
|
||
initial={{ x: "100%" }}
|
||
animate={{ x: 0 }}
|
||
exit={{ x: "100%" }}
|
||
transition={{ type: "spring", damping: 28, stiffness: 260 }}
|
||
className={`fixed top-0 ${dir === "rtl" ? "right-0" : "left-0"} h-full w-full max-w-sm bg-[#0f0f0f] border-l border-white/10 z-[201] flex flex-col shadow-2xl`}
|
||
dir={dir}
|
||
>
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between p-5 border-b border-white/10">
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-8 h-8 bg-orange-500 rounded-lg flex items-center justify-center">
|
||
<span className="text-white font-black text-sm">X</span>
|
||
</div>
|
||
<span className="text-white font-black text-lg">
|
||
{t("store_name")}
|
||
</span>
|
||
</div>
|
||
<button
|
||
onClick={onClose}
|
||
className="text-white/40 hover:text-white p-1.5 rounded-lg hover:bg-white/8 transition-colors"
|
||
>
|
||
<svg
|
||
className="w-5 h-5"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M6 18L18 6M6 6l12 12"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
{/* Tabs */}
|
||
<div className="flex border-b border-white/10 mx-5 mt-4">
|
||
{(["login", "register"] as const).map((m) => (
|
||
<button
|
||
key={m}
|
||
onClick={() => setMode(m)}
|
||
className={`flex-1 py-2.5 text-sm font-bold transition-all border-b-2 ${mode === m ? "text-orange-400 border-orange-400" : "text-white/40 border-transparent hover:text-white/70"}`}
|
||
>
|
||
{m === "login" ? t("auth_login_tab") : t("auth_register_tab")}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Social buttons */}
|
||
<div className="px-5 pt-5 space-y-3">
|
||
<button className="w-full flex items-center justify-center gap-3 bg-white text-gray-800 font-semibold py-2.5 rounded-xl hover:bg-gray-100 transition-colors text-sm">
|
||
<svg className="w-5 h-5" viewBox="0 0 24 24">
|
||
<path
|
||
fill="#4285F4"
|
||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||
/>
|
||
<path
|
||
fill="#34A853"
|
||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||
/>
|
||
<path
|
||
fill="#FBBC05"
|
||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||
/>
|
||
<path
|
||
fill="#EA4335"
|
||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||
/>
|
||
</svg>
|
||
{mode === "login"
|
||
? t("auth_google_login")
|
||
: t("auth_google_register")}
|
||
</button>
|
||
<button className="w-full flex items-center justify-center gap-3 bg-[#111] text-white border border-white/15 font-semibold py-2.5 rounded-xl hover:bg-[#1a1a1a] transition-colors text-sm">
|
||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="white">
|
||
<path d="M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.7 9.05 7.43c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.39-1.32 2.76-2.54 3.96zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z" />
|
||
</svg>
|
||
{mode === "login"
|
||
? t("auth_apple_login")
|
||
: t("auth_apple_register")}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Divider */}
|
||
<div className="flex items-center gap-3 px-5 py-4">
|
||
<div className="flex-1 h-px bg-white/10" />
|
||
<span className="text-xs text-white/30">{t("auth_divider")}</span>
|
||
<div className="flex-1 h-px bg-white/10" />
|
||
</div>
|
||
|
||
{/* Form */}
|
||
<form
|
||
onSubmit={handleSubmit}
|
||
className="px-5 flex-1 overflow-y-auto space-y-3"
|
||
>
|
||
{mode === "register" && (
|
||
<div>
|
||
<label className="block text-xs text-white/50 mb-1.5">
|
||
{t("auth_full_name")}
|
||
</label>
|
||
<input
|
||
value={form.name}
|
||
onChange={(e) => set("name", e.target.value)}
|
||
placeholder={t("auth_name_placeholder")}
|
||
className="w-full bg-white/6 border border-white/12 text-white placeholder-white/25 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:border-orange-500/60"
|
||
style={{ fontSize: "16px" }}
|
||
/>
|
||
</div>
|
||
)}
|
||
<div>
|
||
<label className="block text-xs text-white/50 mb-1.5">
|
||
{t("auth_email")}
|
||
</label>
|
||
<input
|
||
type="email"
|
||
value={form.email}
|
||
onChange={(e) => set("email", e.target.value)}
|
||
required
|
||
placeholder="example@email.com"
|
||
className="w-full bg-white/6 border border-white/12 text-white placeholder-white/25 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:border-orange-500/60"
|
||
style={{ fontSize: "16px" }}
|
||
dir="ltr"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs text-white/50 mb-1.5">
|
||
{t("auth_password")}
|
||
</label>
|
||
<div className="relative">
|
||
<input
|
||
type={showPass ? "text" : "password"}
|
||
value={form.password}
|
||
onChange={(e) => set("password", e.target.value)}
|
||
required
|
||
placeholder={t("auth_pass_placeholder")}
|
||
className="w-full bg-white/6 border border-white/12 text-white placeholder-white/25 rounded-xl px-4 py-2.5 pl-10 text-sm focus:outline-none focus:border-orange-500/60"
|
||
style={{ fontSize: "16px" }}
|
||
dir="ltr"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowPass(!showPass)}
|
||
className="absolute left-3 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60"
|
||
>
|
||
<svg
|
||
className="w-4 h-4"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
{showPass ? (
|
||
<>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||
/>
|
||
</>
|
||
) : (
|
||
<>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||
/>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||
/>
|
||
</>
|
||
)}
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
{mode === "register" && (
|
||
<p className="text-[10px] text-white/25 mt-1.5">
|
||
{t("auth_password_hint")}
|
||
</p>
|
||
)}
|
||
</div>
|
||
{mode === "register" && (
|
||
<div>
|
||
<label className="block text-xs text-white/50 mb-1.5">
|
||
{t("auth_confirm_password")}
|
||
</label>
|
||
<input
|
||
type={showPass ? "text" : "password"}
|
||
value={form.confirm}
|
||
onChange={(e) => set("confirm", e.target.value)}
|
||
required
|
||
placeholder={t("auth_confirm_placeholder")}
|
||
className="w-full bg-white/6 border border-white/12 text-white placeholder-white/25 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:border-orange-500/60"
|
||
style={{ fontSize: "16px" }}
|
||
dir="ltr"
|
||
/>
|
||
</div>
|
||
)}
|
||
{mode === "login" && (
|
||
<label className="flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={form.remember}
|
||
onChange={(e) => set("remember", e.target.checked)}
|
||
className="accent-orange-500 w-4 h-4"
|
||
/>
|
||
<span className="text-sm text-white/50">
|
||
{t("auth_remember")}
|
||
</span>
|
||
</label>
|
||
)}
|
||
|
||
<button
|
||
type="submit"
|
||
disabled={loading}
|
||
className="w-full bg-orange-500 hover:bg-orange-600 disabled:opacity-60 text-white font-bold py-3 rounded-xl transition-colors text-sm mt-2"
|
||
>
|
||
{loading
|
||
? t("auth_loading")
|
||
: mode === "login"
|
||
? t("auth_login_btn")
|
||
: t("auth_register_btn")}
|
||
</button>
|
||
|
||
<p className="text-center text-xs text-white/30 pb-4">
|
||
{mode === "login"
|
||
? t("auth_no_account")
|
||
: t("auth_has_account")}
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
setMode(mode === "login" ? "register" : "login")
|
||
}
|
||
className="text-orange-400 hover:text-orange-300 font-medium"
|
||
>
|
||
{mode === "login"
|
||
? t("auth_create_link")
|
||
: t("auth_login_link")}
|
||
</button>
|
||
</p>
|
||
</form>
|
||
</motion.div>
|
||
</>
|
||
)}
|
||
</AnimatePresence>
|
||
);
|
||
}
|
||
|
||
// ─── Sound Notifications ─────────────────────────────
|
||
declare global {
|
||
interface Window {
|
||
webkitAudioContext?: typeof AudioContext;
|
||
}
|
||
}
|
||
function playSound(type: "success" | "error" | "info" = "success") {
|
||
try {
|
||
const ctx = new (window.AudioContext || window.webkitAudioContext!)();
|
||
const masterGain = ctx.createGain();
|
||
masterGain.connect(ctx.destination);
|
||
masterGain.gain.setValueAtTime(0.18, ctx.currentTime);
|
||
|
||
const play = (
|
||
freq: number,
|
||
start: number,
|
||
dur: number,
|
||
wave: OscillatorType = "sine",
|
||
) => {
|
||
const osc = ctx.createOscillator();
|
||
const gain = ctx.createGain();
|
||
osc.connect(gain);
|
||
gain.connect(masterGain);
|
||
osc.type = wave;
|
||
osc.frequency.setValueAtTime(freq, ctx.currentTime + start);
|
||
gain.gain.setValueAtTime(0, ctx.currentTime + start);
|
||
gain.gain.linearRampToValueAtTime(1, ctx.currentTime + start + 0.01);
|
||
gain.gain.exponentialRampToValueAtTime(
|
||
0.001,
|
||
ctx.currentTime + start + dur,
|
||
);
|
||
osc.start(ctx.currentTime + start);
|
||
osc.stop(ctx.currentTime + start + dur);
|
||
};
|
||
|
||
if (type === "success") {
|
||
play(523, 0, 0.15); // C5
|
||
play(659, 0.1, 0.15); // E5
|
||
play(784, 0.2, 0.25); // G5
|
||
} else if (type === "error") {
|
||
play(330, 0, 0.18, "sawtooth");
|
||
play(247, 0.15, 0.25, "sawtooth");
|
||
} else {
|
||
play(660, 0, 0.12);
|
||
play(660, 0.14, 0.18);
|
||
}
|
||
|
||
setTimeout(() => ctx.close(), 1000);
|
||
} catch (_) {}
|
||
}
|
||
|
||
// ─── Toast Notification ──────────────────────────────
|
||
interface ToastItem {
|
||
id: number;
|
||
msg: string;
|
||
type?: "success" | "error" | "info";
|
||
}
|
||
function useToast() {
|
||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||
const show = useCallback(
|
||
(msg: string, type: ToastItem["type"] = "success") => {
|
||
const id = Date.now();
|
||
setToasts((t) => [...t, { id, msg, type }]);
|
||
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 3000);
|
||
},
|
||
[],
|
||
);
|
||
const dismiss = useCallback((id: number) => {
|
||
setToasts((t) => t.filter((x) => x.id !== id));
|
||
}, []);
|
||
return { toasts, show, dismiss };
|
||
}
|
||
const ToastContext = createContext<{
|
||
show: (msg: string, type?: ToastItem["type"]) => void;
|
||
}>({ show: () => {} });
|
||
function useShowToast() {
|
||
return useContext(ToastContext).show;
|
||
}
|
||
|
||
function ToastProvider({ children }: { children: React.ReactNode }) {
|
||
const { toasts, show, dismiss } = useToast();
|
||
return (
|
||
<ToastContext.Provider value={{ show }}>
|
||
{children}
|
||
<div
|
||
className="fixed bottom-6 right-6 z-[999] flex flex-col gap-3 items-end pointer-events-none"
|
||
dir="rtl"
|
||
>
|
||
{toasts.map((t) => (
|
||
<div
|
||
key={t.id}
|
||
className="pointer-events-auto flex items-center gap-3 bg-[#111] border border-[#D4AF37]/40 text-white text-sm px-4 py-3.5 rounded-2xl shadow-2xl shadow-black/60 min-w-[280px] max-w-[360px]"
|
||
style={{ animation: "slideInRight 0.3s ease-out" }}
|
||
>
|
||
<div
|
||
className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 ${
|
||
t.type === "error"
|
||
? "bg-red-500/15 border border-red-500/30"
|
||
: "bg-[#D4AF37]/15 border border-[#D4AF37]/30"
|
||
}`}
|
||
>
|
||
{t.type === "error" ? (
|
||
<svg
|
||
className="w-4 h-4 text-red-400"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M6 18L18 6M6 6l12 12"
|
||
/>
|
||
</svg>
|
||
) : (
|
||
<svg
|
||
className="w-4 h-4 text-[#D4AF37]"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2.5}
|
||
d="M5 13l4 4L19 7"
|
||
/>
|
||
</svg>
|
||
)}
|
||
</div>
|
||
<span className="flex-1 leading-snug">{t.msg}</span>
|
||
<button
|
||
onClick={() => dismiss(t.id)}
|
||
className="text-gray-600 hover:text-gray-400 transition-colors shrink-0 p-0.5"
|
||
>
|
||
<svg
|
||
className="w-3.5 h-3.5"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M6 18L18 6M6 6l12 12"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</ToastContext.Provider>
|
||
);
|
||
}
|
||
|
||
// ─── Types ──────────────────────────────────────────
|
||
interface Category {
|
||
id: number;
|
||
name: string;
|
||
name_en: string | null;
|
||
icon: string | null;
|
||
parent_id: number | null;
|
||
sort_order: number | null;
|
||
source: string | null;
|
||
slug: string | null;
|
||
shein_url: string | null;
|
||
image_url: string | null;
|
||
product_count?: number;
|
||
}
|
||
interface Product {
|
||
id: number;
|
||
name: string;
|
||
name_en: string | null;
|
||
brand: string | null;
|
||
price: string;
|
||
original_price: string | null;
|
||
images: string[];
|
||
colors: string[];
|
||
sizes: string[];
|
||
specs: Record<string, string>;
|
||
marketing_points: string[];
|
||
subcategory: string | null;
|
||
category_id: number;
|
||
rating: string;
|
||
review_count: number;
|
||
stock: number;
|
||
is_trending: boolean;
|
||
is_bestseller: boolean;
|
||
is_new: boolean;
|
||
is_top_rated: boolean;
|
||
}
|
||
interface ProductsResp {
|
||
products: Product[];
|
||
total: number;
|
||
page: number;
|
||
total_pages: number;
|
||
}
|
||
|
||
// ─── Extended Types ──────────────────────────────────
|
||
interface CategoryNode extends Category {
|
||
children: Category[];
|
||
}
|
||
|
||
// ─── Hooks ──────────────────────────────────────────
|
||
function useCategories() {
|
||
return useQuery<Category[]>({
|
||
queryKey: ["categories"],
|
||
queryFn: async () => {
|
||
const cats = await fetchJsonOrFallback<Category[]>(
|
||
`${API}/categories`,
|
||
() => Promise.resolve(getFallbackCategories() as Category[]),
|
||
);
|
||
return cats.filter((c) => !c.parent_id);
|
||
},
|
||
});
|
||
}
|
||
function useCategoryTree() {
|
||
return useQuery<CategoryNode[]>({
|
||
queryKey: ["categories-tree"],
|
||
queryFn: () =>
|
||
fetchJsonOrFallback<CategoryNode[]>(`${API}/categories/tree`, () =>
|
||
Promise.resolve(getFallbackCategoryTree() as CategoryNode[]),
|
||
),
|
||
});
|
||
}
|
||
function useProducts(params: Record<string, string | number | undefined>) {
|
||
const qs = Object.entries(params)
|
||
.filter(([, v]) => v !== undefined)
|
||
.map(([k, v]) => `${k}=${v}`)
|
||
.join("&");
|
||
return useQuery<ProductsResp>({
|
||
queryKey: ["products", qs],
|
||
queryFn: () =>
|
||
fetchJsonOrFallback<ProductsResp>(`${API}/products?${qs}`, () =>
|
||
Promise.resolve(getFallbackProducts(params) as ProductsResp),
|
||
),
|
||
});
|
||
}
|
||
function useProduct(id: number) {
|
||
return useQuery<Product>({
|
||
queryKey: ["product", id],
|
||
queryFn: () =>
|
||
fetchJsonOrFallback<Product>(`${API}/products/${id}`, () =>
|
||
Promise.resolve(getFallbackProduct(id) as Product),
|
||
),
|
||
});
|
||
}
|
||
function useStoreSettings() {
|
||
return useQuery<Record<string, string>>({
|
||
queryKey: ["store-settings"],
|
||
queryFn: () =>
|
||
fetchJsonOrFallback<Record<string, string>>(
|
||
`${API}/public-settings`,
|
||
() => Promise.resolve(FALLBACK_STORE_SETTINGS),
|
||
),
|
||
staleTime: 30_000,
|
||
});
|
||
}
|
||
|
||
function storeCopy(
|
||
settings: Record<string, string> | undefined,
|
||
lang: Lang,
|
||
key: string,
|
||
fallback: string,
|
||
) {
|
||
const arValue = settings?.[`${key}_ar`] ?? settings?.[key];
|
||
const enValue = settings?.[`${key}_en`];
|
||
return lang === "en" ? enValue || arValue || fallback : arValue || fallback;
|
||
}
|
||
|
||
// ─── Announcement Bar ────────────────────────────────
|
||
function AnnouncementBar() {
|
||
const { data: s } = useStoreSettings();
|
||
const { lang } = useLang();
|
||
if (!s || s.announcement_enabled !== "true") return null;
|
||
const text =
|
||
lang === "en" && s.announcement_text_en
|
||
? s.announcement_text_en
|
||
: s.announcement_text || "";
|
||
const bg = s.announcement_color || "#f97316";
|
||
const tc = s.announcement_text_color || "#ffffff";
|
||
return (
|
||
<div
|
||
className="w-full overflow-hidden py-2"
|
||
style={{ backgroundColor: bg }}
|
||
>
|
||
<div
|
||
className="flex"
|
||
style={{ animation: "marquee 28s linear infinite" }}
|
||
>
|
||
{[0, 1, 2].map((i) => (
|
||
<span
|
||
key={i}
|
||
className="whitespace-nowrap text-sm font-medium px-12 shrink-0"
|
||
style={{ color: tc }}
|
||
>
|
||
{text}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Components ─────────────────────────────────────
|
||
function StarRating({
|
||
rating,
|
||
count,
|
||
}: {
|
||
rating?: string | null;
|
||
count?: number | null;
|
||
}) {
|
||
const r = parseFloat(rating ?? "0") || 0;
|
||
return (
|
||
<div className="flex items-center gap-1.5">
|
||
<div className="flex">
|
||
{[1, 2, 3, 4, 5].map((i) => (
|
||
<svg
|
||
key={i}
|
||
className={`w-3.5 h-3.5 ${i <= Math.round(r) ? "text-amber-400" : "text-white/20"}`}
|
||
fill="currentColor"
|
||
viewBox="0 0 20 20"
|
||
>
|
||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||
</svg>
|
||
))}
|
||
</div>
|
||
{(count ?? 0) > 0 && (
|
||
<span className="text-xs text-white/50">
|
||
({(count ?? 0).toLocaleString("ar-SA")})
|
||
</span>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ProductCard({ p }: { p: Product }) {
|
||
const discount = p.original_price
|
||
? Math.round((1 - parseFloat(p.price) / parseFloat(p.original_price)) * 100)
|
||
: 0;
|
||
const img = (Array.isArray(p.images) && p.images[0]) || "";
|
||
const [imgLoaded, setImgLoaded] = useState(false);
|
||
const [imgError, setImgError] = useState(false);
|
||
const [added, setAdded] = useState(false);
|
||
const { addItem } = useCart();
|
||
const showToast = useShowToast();
|
||
const { t, lang } = useLang();
|
||
|
||
const handleAddToCart = (e: React.MouseEvent) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
addItem(p, 1, p.colors?.[0] ?? null, p.sizes?.[0] ?? null);
|
||
const name = lang === "en" && p.name_en ? p.name_en : p.name;
|
||
showToast(`${t("added_toast_short")} ${name.substring(0, 30)}...`);
|
||
setAdded(true);
|
||
setTimeout(() => setAdded(false), 2000);
|
||
};
|
||
|
||
return (
|
||
<Link href={`/product/${p.id}`}>
|
||
<div className="group bg-[#111] border border-white/8 rounded-xl overflow-hidden cursor-pointer hover:border-orange-500/40 transition-all duration-300 hover:shadow-[0_0_24px_rgba(255,102,0,0.18)] flex flex-col h-full">
|
||
{/* Image area — white background exactly like Extra store */}
|
||
<div className="relative aspect-square bg-white overflow-hidden">
|
||
{discount > 0 && (
|
||
<span className="absolute top-2 right-2 z-10 bg-red-500 text-white text-xs font-bold px-2 py-0.5 rounded-md shadow">
|
||
-{discount}%
|
||
</span>
|
||
)}
|
||
{p.is_new && (
|
||
<span className="absolute top-2 left-2 z-10 bg-orange-500 text-white text-xs font-bold px-2 py-0.5 rounded-md shadow">
|
||
{t("product_new")}
|
||
</span>
|
||
)}
|
||
{img && !imgError ? (
|
||
<img
|
||
src={proxyImg(img)}
|
||
alt={p.name}
|
||
onLoad={() => setImgLoaded(true)}
|
||
onError={() => setImgError(true)}
|
||
className={`w-full h-full object-contain p-4 group-hover:scale-108 transition-transform duration-500 ${imgLoaded ? "opacity-100" : "opacity-0"}`}
|
||
/>
|
||
) : imgError ? (
|
||
<div className="absolute inset-0 flex flex-col items-center justify-center text-gray-300 gap-2">
|
||
<svg
|
||
className="w-10 h-10 text-gray-200"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={1}
|
||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||
/>
|
||
</svg>
|
||
</div>
|
||
) : null}
|
||
{!imgLoaded && !imgError && img && (
|
||
<div className="absolute inset-0 flex items-center justify-center bg-white">
|
||
<div className="w-8 h-8 border-2 border-gray-200 border-t-orange-400 rounded-full animate-spin" />
|
||
</div>
|
||
)}
|
||
{/* Quick Add Button — always visible on mobile, hover on desktop */}
|
||
<button
|
||
onClick={handleAddToCart}
|
||
className={`absolute bottom-2 left-0 right-0 mx-2 py-1.5 rounded-lg text-xs font-bold transition-all duration-200 sm:opacity-0 sm:group-hover:opacity-100 opacity-100 ${
|
||
added
|
||
? "bg-emerald-500 text-white"
|
||
: "bg-orange-500 hover:bg-orange-600 text-white"
|
||
}`}
|
||
>
|
||
{added ? t("product_added") : t("product_add")}
|
||
</button>
|
||
</div>
|
||
{/* Product info — dark */}
|
||
<div className="p-3 flex flex-col gap-1.5 flex-1 border-t border-white/6">
|
||
{p.brand && (
|
||
<span className="text-orange-400 text-xs font-bold tracking-wider uppercase">
|
||
{p.brand}
|
||
</span>
|
||
)}
|
||
<h3 className="text-sm font-medium text-white/90 line-clamp-2 leading-snug">
|
||
{lang === "en" && p.name_en ? p.name_en : p.name}
|
||
</h3>
|
||
<StarRating rating={p.rating} count={p.review_count} />
|
||
<div className="mt-auto pt-2">
|
||
<div className="flex items-baseline gap-2 flex-wrap">
|
||
<span className="text-orange-400 font-bold text-base">
|
||
{parseFloat(p.price).toLocaleString("ar-SA")} {t("currency")}
|
||
</span>
|
||
{p.original_price &&
|
||
parseFloat(p.original_price) > parseFloat(p.price) && (
|
||
<span className="text-white/30 text-xs line-through">
|
||
{parseFloat(p.original_price).toLocaleString("ar-SA")}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Link>
|
||
);
|
||
}
|
||
|
||
function SheinMegaMenu({
|
||
tree,
|
||
onClose,
|
||
}: {
|
||
tree: CategoryNode[];
|
||
onClose: () => void;
|
||
}) {
|
||
const [activeSection, setActiveSection] = useState<CategoryNode | null>(null);
|
||
const { t, lang } = useLang();
|
||
const sheinSections = tree.filter((n) => n.source === "shein");
|
||
const active = activeSection ?? sheinSections[0] ?? null;
|
||
const catName = (c: CategoryNode & { name_en?: string }) =>
|
||
lang === "en" && c.name_en ? c.name_en : c.name;
|
||
|
||
return (
|
||
<div
|
||
className="absolute top-full right-0 left-0 z-50 bg-[#0f0f0f] border-t border-b border-white/10 shadow-2xl shadow-black/80"
|
||
onMouseLeave={onClose}
|
||
>
|
||
<div className="max-w-screen-xl mx-auto flex" style={{ minHeight: 360 }}>
|
||
{/* Left: Category list with thumbnail images */}
|
||
<div className="w-56 shrink-0 border-l border-white/8 py-2 overflow-y-auto">
|
||
{sheinSections.map((sec) => (
|
||
<button
|
||
key={sec.id}
|
||
onMouseEnter={() => setActiveSection(sec)}
|
||
onClick={() => {
|
||
setActiveSection(sec);
|
||
}}
|
||
className={`w-full text-right px-3 py-2 text-sm flex items-center gap-3 transition-all ${
|
||
active?.id === sec.id
|
||
? "bg-white/8 text-white border-r-2 border-white"
|
||
: "text-white/60 hover:bg-white/4 hover:text-white/90"
|
||
}`}
|
||
>
|
||
{/* Small category thumbnail */}
|
||
<div className="w-9 h-12 rounded overflow-hidden shrink-0 bg-[#222]">
|
||
{(sec as CategoryNode & { image_url?: string }).image_url ? (
|
||
<img
|
||
src={
|
||
(sec as CategoryNode & { image_url?: string }).image_url
|
||
}
|
||
alt={sec.name}
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
) : (
|
||
<div className="w-full h-full flex items-center justify-center text-lg">
|
||
{sec.icon ?? "🏷️"}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="text-right">
|
||
<span className="font-semibold text-sm block leading-tight">
|
||
{catName(sec as CategoryNode & { name_en?: string })}
|
||
</span>
|
||
<span className="text-[10px] text-white/35 block mt-0.5">
|
||
{(sec as CategoryNode & { name_en?: string }).name_en}
|
||
</span>
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Right: Subcategories + promo area */}
|
||
<div className="flex-1 flex overflow-hidden">
|
||
{/* Subcategories */}
|
||
<div className="flex-1 p-6">
|
||
{active && (
|
||
<>
|
||
<div className="flex items-center justify-between mb-4 pb-3 border-b border-white/8">
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-7 h-9 rounded overflow-hidden bg-[#222] shrink-0">
|
||
{(active as CategoryNode & { image_url?: string })
|
||
.image_url ? (
|
||
<img
|
||
src={
|
||
(active as CategoryNode & { image_url?: string })
|
||
.image_url
|
||
}
|
||
alt={active.name}
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
) : (
|
||
<span className="text-base flex items-center justify-center h-full">
|
||
{active.icon}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div>
|
||
<h3 className="text-white font-bold text-sm leading-tight">
|
||
{catName(active as CategoryNode & { name_en?: string })}
|
||
</h3>
|
||
<p className="text-white/35 text-[10px]">
|
||
{
|
||
(active as CategoryNode & { name_en?: string })
|
||
.name_en
|
||
}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<Link
|
||
href={`/category/${active.id}`}
|
||
onClick={onClose}
|
||
className="text-xs text-white/40 hover:text-white transition-colors"
|
||
>
|
||
{t("view_all")}
|
||
</Link>
|
||
</div>
|
||
{active.children && active.children.length > 0 ? (
|
||
<div className="grid grid-cols-3 gap-x-8 gap-y-1.5">
|
||
{active.children.map((sub) => (
|
||
<Link
|
||
key={sub.id}
|
||
href={`/category/${sub.id}`}
|
||
onClick={onClose}
|
||
className="flex items-center gap-2 py-1 text-sm text-white/55 hover:text-white transition-colors group"
|
||
>
|
||
<span className="text-base shrink-0 opacity-70 group-hover:opacity-100 transition-opacity">
|
||
{sub.icon}
|
||
</span>
|
||
<span className="leading-tight">
|
||
{catName(sub as CategoryNode & { name_en?: string })}
|
||
</span>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="flex items-center justify-center h-24 text-white/20 text-sm">
|
||
{t("browse_all_cat")}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Promo panel — active category hero image */}
|
||
{active &&
|
||
(active as CategoryNode & { image_url?: string }).image_url && (
|
||
<div className="w-40 shrink-0 p-3 flex items-center justify-center border-r border-white/6">
|
||
<Link
|
||
href={`/category/${active.id}`}
|
||
onClick={onClose}
|
||
className="block w-full"
|
||
>
|
||
<div
|
||
className="relative rounded-lg overflow-hidden w-full"
|
||
style={{ aspectRatio: "2/3" }}
|
||
>
|
||
<img
|
||
src={
|
||
(active as CategoryNode & { image_url?: string })
|
||
.image_url
|
||
}
|
||
alt={active.name}
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 to-transparent" />
|
||
<div className="absolute bottom-3 inset-x-0 text-center">
|
||
<span className="text-white text-xs font-bold">
|
||
{catName(active as CategoryNode & { name_en?: string })}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</Link>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Header() {
|
||
const { data: allCats } = useCategories();
|
||
const { data: tree } = useCategoryTree();
|
||
const { data: s } = useStoreSettings();
|
||
const [search, setSearch] = useState("");
|
||
const [, navigate] = useLocation();
|
||
const { count } = useCart();
|
||
const { user, logout, openAuth } = useAuth();
|
||
const { t, lang, setLang, dir } = useLang();
|
||
const featuredMenu = FEATURED_MENU_ITEMS.map((item) => ({
|
||
...item,
|
||
label: lang === "en" ? item.label_en : item.label_ar,
|
||
}));
|
||
const storeName = storeCopy(s, lang, "store_name", t("store_name"));
|
||
const topBarOffer = storeCopy(s, lang, "top_bar_offer", t("top_bar_offer"));
|
||
const searchPlaceholder = storeCopy(
|
||
s,
|
||
lang,
|
||
"header_search_placeholder",
|
||
t("search_placeholder"),
|
||
);
|
||
const menuStripLabel = storeCopy(
|
||
s,
|
||
lang,
|
||
"menu_strip_label",
|
||
lang === "en" ? "Store Menus" : "القوائم",
|
||
);
|
||
const [sheinOpen, setSheinOpen] = useState(false);
|
||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||
const navRef = useRef<HTMLDivElement>(null);
|
||
const scrollNav = (d: "left" | "right") => {
|
||
if (navRef.current)
|
||
navRef.current.scrollBy({
|
||
left: d === "left" ? -200 : 200,
|
||
behavior: "smooth",
|
||
});
|
||
};
|
||
|
||
const extraCats =
|
||
allCats?.filter((c) => !c.source || c.source === "extra") ?? [];
|
||
const sheinTree = tree?.filter((n) => n.source === "shein") ?? [];
|
||
|
||
return (
|
||
<header className="sticky top-0 z-50 bg-[#0a0a0a] border-b border-white/10">
|
||
{/* Top bar */}
|
||
<div className="bg-orange-600 text-white text-center text-xs py-1.5 font-medium">
|
||
{topBarOffer}
|
||
</div>
|
||
{/* Main header */}
|
||
<div className="max-w-screen-xl mx-auto px-3 sm:px-4 py-2.5 sm:py-3 flex items-center gap-2 sm:gap-4">
|
||
<Link href="/" className="flex items-center gap-1.5 sm:gap-2 shrink-0">
|
||
<div className="w-8 h-8 bg-orange-500 rounded-lg flex items-center justify-center">
|
||
<span className="text-white font-black text-sm">X</span>
|
||
</div>
|
||
<span className="text-white font-black text-xl tracking-tight">
|
||
{storeName}
|
||
</span>
|
||
</Link>
|
||
|
||
<form
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
navigate(`/category/0?q=${encodeURIComponent(search)}`);
|
||
}}
|
||
className="flex-1"
|
||
>
|
||
<div className="relative">
|
||
<input
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
placeholder={searchPlaceholder}
|
||
className="w-full bg-white/8 border border-white/12 text-white placeholder-white/35 rounded-xl py-2.5 pr-4 pl-10 text-sm focus:outline-none focus:border-orange-500/60"
|
||
style={{ fontSize: "16px" }}
|
||
dir={lang === "en" ? "ltr" : "rtl"}
|
||
/>
|
||
<button
|
||
type="submit"
|
||
className="absolute left-3 top-1/2 -translate-y-1/2 text-white/40 hover:text-orange-400 transition-colors"
|
||
>
|
||
<svg
|
||
className="w-4 h-4"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</form>
|
||
|
||
{/* Language Toggle */}
|
||
<button
|
||
onClick={() => setLang(lang === "ar" ? "en" : "ar")}
|
||
className="shrink-0 w-10 h-10 flex items-center justify-center rounded-xl border border-white/12 hover:border-[#D4AF37]/50 hover:bg-[#D4AF37]/10 transition-all group"
|
||
title={lang === "ar" ? "Switch to English" : "التبديل للعربية"}
|
||
>
|
||
<span className="text-white/60 group-hover:text-[#D4AF37] font-bold text-xs transition-colors tracking-tight">
|
||
{lang === "ar" ? "EN" : "ع"}
|
||
</span>
|
||
</button>
|
||
|
||
{/* User button */}
|
||
<div className="relative shrink-0">
|
||
{user ? (
|
||
<>
|
||
<button
|
||
onClick={() => setUserMenuOpen((v) => !v)}
|
||
className="w-10 h-10 flex items-center justify-center rounded-xl border border-orange-500/40 bg-orange-500/10 hover:bg-orange-500/20 transition-all"
|
||
>
|
||
<span className="text-orange-400 font-black text-sm">
|
||
{(user.name || user.email)[0].toUpperCase()}
|
||
</span>
|
||
</button>
|
||
{userMenuOpen && (
|
||
<div
|
||
className={`absolute top-12 ${dir === "rtl" ? "left-0" : "right-0"} w-52 bg-[#111] border border-white/12 rounded-2xl shadow-2xl z-50 overflow-hidden`}
|
||
dir={dir}
|
||
>
|
||
<div className="px-4 py-3 border-b border-white/8">
|
||
<p className="text-white font-bold text-sm truncate">
|
||
{user.name || t("user_guest")}
|
||
</p>
|
||
<p className="text-white/40 text-xs truncate">
|
||
{user.email}
|
||
</p>
|
||
</div>
|
||
<Link
|
||
href="/profile"
|
||
onClick={() => setUserMenuOpen(false)}
|
||
className="flex items-center gap-3 px-4 py-2.5 text-sm text-white/70 hover:text-white hover:bg-white/5 transition-colors"
|
||
>
|
||
<svg
|
||
className="w-4 h-4"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||
/>
|
||
</svg>
|
||
{t("user_profile")}
|
||
</Link>
|
||
<Link
|
||
href="/cart"
|
||
onClick={() => setUserMenuOpen(false)}
|
||
className="flex items-center gap-3 px-4 py-2.5 text-sm text-white/70 hover:text-white hover:bg-white/5 transition-colors"
|
||
>
|
||
<svg
|
||
className="w-4 h-4"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z"
|
||
/>
|
||
</svg>
|
||
{t("user_cart")}
|
||
</Link>
|
||
<button
|
||
onClick={() => {
|
||
logout();
|
||
setUserMenuOpen(false);
|
||
}}
|
||
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-red-400 hover:bg-red-500/10 transition-colors"
|
||
>
|
||
<svg
|
||
className="w-4 h-4"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||
/>
|
||
</svg>
|
||
{t("user_logout")}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</>
|
||
) : (
|
||
<button
|
||
onClick={() => openAuth("login")}
|
||
className="w-10 h-10 flex items-center justify-center rounded-xl border border-white/12 hover:border-orange-500/50 hover:bg-orange-500/10 transition-all group"
|
||
>
|
||
<svg
|
||
className="w-5 h-5 text-white/60 group-hover:text-orange-400 transition-colors"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={1.8}
|
||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Cart Icon */}
|
||
<Link
|
||
href="/cart"
|
||
className="relative shrink-0 w-10 h-10 flex items-center justify-center rounded-xl border border-white/12 hover:border-orange-500/50 hover:bg-orange-500/10 transition-all group"
|
||
>
|
||
<svg
|
||
className="w-5 h-5 text-white/70 group-hover:text-orange-400 transition-colors"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={1.8}
|
||
d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z"
|
||
/>
|
||
</svg>
|
||
{count > 0 && (
|
||
<span className="absolute -top-1.5 -right-1.5 min-w-[18px] h-[18px] bg-orange-500 text-white text-[10px] font-bold rounded-full flex items-center justify-center px-1 shadow">
|
||
{count > 99 ? "99+" : count}
|
||
</span>
|
||
)}
|
||
</Link>
|
||
</div>
|
||
|
||
{/* Category nav */}
|
||
<nav className="relative border-t border-white/8">
|
||
<button
|
||
onClick={() => scrollNav("left")}
|
||
aria-label="scroll left"
|
||
className="absolute left-0 top-0 h-full w-8 z-10 flex items-center justify-center bg-gradient-to-r from-[#0a0a0a] to-transparent text-white/40 hover:text-orange-400 transition-colors"
|
||
>
|
||
<svg
|
||
className="w-4 h-4"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2.5}
|
||
d="M15 19l-7-7 7-7"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
<button
|
||
onClick={() => scrollNav("right")}
|
||
aria-label="scroll right"
|
||
className="absolute right-0 top-0 h-full w-8 z-10 flex items-center justify-center bg-gradient-to-l from-[#0a0a0a] to-transparent text-white/40 hover:text-orange-400 transition-colors"
|
||
>
|
||
<svg
|
||
className="w-4 h-4"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2.5}
|
||
d="M9 5l7 7-7 7"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
<div
|
||
ref={navRef}
|
||
className="max-w-screen-xl mx-auto px-8 flex items-center gap-0 overflow-x-auto scrollbar-hide"
|
||
>
|
||
<Link
|
||
href="/category/0"
|
||
className="shrink-0 px-3 py-2.5 text-sm text-white/70 hover:text-orange-400 hover:bg-orange-500/10 transition-colors whitespace-nowrap border-b-2 border-transparent hover:border-orange-500"
|
||
>
|
||
{t("nav_all")}
|
||
</Link>
|
||
|
||
{extraCats.map((c) => (
|
||
<Link
|
||
key={c.id}
|
||
href={`/category/${c.id}`}
|
||
className="shrink-0 px-3 py-2.5 text-sm text-white/70 hover:text-orange-400 hover:bg-orange-500/10 transition-colors whitespace-nowrap border-b-2 border-transparent hover:border-orange-500 flex items-center gap-1.5"
|
||
>
|
||
<span>{c.icon}</span>
|
||
<span>{lang === "en" && c.name_en ? c.name_en : c.name}</span>
|
||
</Link>
|
||
))}
|
||
|
||
{sheinTree.length > 0 && (
|
||
<span className="shrink-0 mx-2 text-white/15 text-lg select-none">
|
||
|
|
||
</span>
|
||
)}
|
||
|
||
{sheinTree.length > 0 && (
|
||
<div
|
||
className="relative shrink-0"
|
||
onMouseEnter={() => setSheinOpen(true)}
|
||
>
|
||
<button
|
||
onClick={() => setSheinOpen((v) => !v)}
|
||
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-bold whitespace-nowrap border-b-2 transition-colors ${
|
||
sheinOpen
|
||
? "text-[#D4AF37] border-[#D4AF37] bg-[#D4AF37]/8"
|
||
: "text-[#D4AF37]/80 border-transparent hover:text-[#D4AF37] hover:border-[#D4AF37]/50 hover:bg-[#D4AF37]/8"
|
||
}`}
|
||
>
|
||
<span>🛍️</span>
|
||
<span>{t("nav_shein")}</span>
|
||
<svg
|
||
className={`w-3.5 h-3.5 transition-transform ${sheinOpen ? "rotate-180" : ""}`}
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2.5}
|
||
d="M19 9l-7 7-7-7"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
|
||
{sheinOpen && (
|
||
<SheinMegaMenu
|
||
tree={sheinTree}
|
||
onClose={() => setSheinOpen(false)}
|
||
/>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</nav>
|
||
|
||
<div className="border-t border-white/6 bg-[#080808]">
|
||
<div className="max-w-screen-xl mx-auto px-4 py-2.5 flex items-center gap-2 overflow-x-auto scrollbar-hide">
|
||
<span className="text-[11px] text-white/35 shrink-0 font-medium">
|
||
{menuStripLabel}
|
||
</span>
|
||
{featuredMenu.map((item) => (
|
||
<a
|
||
key={item.id}
|
||
href={item.href}
|
||
className="shrink-0 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-white/70 hover:text-orange-300 hover:border-orange-500/40 hover:bg-orange-500/10 transition-all"
|
||
>
|
||
{item.label}
|
||
</a>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</header>
|
||
);
|
||
}
|
||
|
||
function Footer() {
|
||
const { t, lang } = useLang();
|
||
const { data: footerCats } = useCategories();
|
||
const { data: s } = useStoreSettings();
|
||
const categoryLinks = (footerCats ?? [])
|
||
.filter((c) => !c.source || c.source === "extra")
|
||
.slice(0, 10);
|
||
const policyLinks = FOOTER_POLICY_LINKS.map((item) => ({
|
||
...item,
|
||
label: lang === "en" ? item.label_en : item.label_ar,
|
||
}));
|
||
const storeName = storeCopy(s, lang, "store_name", t("store_name"));
|
||
const storeTagline = storeCopy(s, lang, "store_tagline", t("store_tagline"));
|
||
const footerAddress = storeCopy(
|
||
s,
|
||
lang,
|
||
"footer_address",
|
||
t("footer_address"),
|
||
);
|
||
const footerContactPhone = s?.footer_contact_phone || "920003117";
|
||
const footerCopyright = storeCopy(
|
||
s,
|
||
lang,
|
||
"footer_copyright",
|
||
t("footer_copyright"),
|
||
);
|
||
return (
|
||
<footer className="bg-[#080808] border-t border-white/8 mt-16 py-10">
|
||
<div className="max-w-screen-xl mx-auto px-4 grid grid-cols-2 md:grid-cols-5 gap-8 text-sm text-white/50">
|
||
<div>
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<div className="w-6 h-6 bg-orange-500 rounded flex items-center justify-center">
|
||
<span className="text-white font-black text-xs">X</span>
|
||
</div>
|
||
<span className="text-white font-bold text-base">{storeName}</span>
|
||
</div>
|
||
<p className="leading-relaxed">{storeTagline}</p>
|
||
</div>
|
||
<div>
|
||
<h4 className="text-white font-semibold mb-3">
|
||
{t("footer_quick_links")}
|
||
</h4>
|
||
<ul className="space-y-2">
|
||
<li>
|
||
<Link
|
||
href="/"
|
||
className="hover:text-orange-400 transition-colors"
|
||
>
|
||
{t("footer_home")}
|
||
</Link>
|
||
</li>
|
||
<li>
|
||
<Link
|
||
href="/category/0"
|
||
className="hover:text-orange-400 transition-colors"
|
||
>
|
||
{t("footer_all_products")}
|
||
</Link>
|
||
</li>
|
||
{FEATURED_MENU_ITEMS.slice(0, 3).map((item) => (
|
||
<li key={item.id}>
|
||
<a
|
||
href={item.href}
|
||
className="hover:text-orange-400 transition-colors"
|
||
>
|
||
{lang === "en" ? item.label_en : item.label_ar}
|
||
</a>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
<div>
|
||
<h4 className="text-white font-semibold mb-3">
|
||
{lang === "en" ? "Product Categories" : "فئات المنتجات"}
|
||
</h4>
|
||
<ul className="space-y-2">
|
||
{categoryLinks.map((c) => (
|
||
<li key={c.id}>
|
||
<Link
|
||
href={`/category/${c.id}`}
|
||
className="hover:text-orange-400 transition-colors"
|
||
>
|
||
{lang === "en" && c.name_en ? c.name_en : c.name}
|
||
</Link>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
<div>
|
||
<h4 className="text-white font-semibold mb-3">
|
||
{lang === "en" ? "Policies & Menus" : "السياسات والقوائم"}
|
||
</h4>
|
||
<ul className="space-y-2">
|
||
{policyLinks.map((item) => (
|
||
<li key={item.label}>
|
||
<a
|
||
href={item.href}
|
||
className="hover:text-orange-400 transition-colors"
|
||
>
|
||
{item.label}
|
||
</a>
|
||
</li>
|
||
))}
|
||
<li>
|
||
<a
|
||
href="/#section-top-rated"
|
||
className="hover:text-orange-400 transition-colors"
|
||
>
|
||
{lang === "en" ? "Top Rated" : "أعلى تقييماً"}
|
||
</a>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
<div>
|
||
<h4 className="text-white font-semibold mb-3">
|
||
{t("footer_contact")}
|
||
</h4>
|
||
<p>{footerContactPhone}</p>
|
||
<p className="mt-1">{footerAddress}</p>
|
||
</div>
|
||
</div>
|
||
<div className="max-w-screen-xl mx-auto px-4 mt-8 pt-6 border-t border-white/8 text-center text-xs text-white/30">
|
||
{footerCopyright}
|
||
</div>
|
||
</footer>
|
||
);
|
||
}
|
||
|
||
// ─── Pages ──────────────────────────────────────────
|
||
function Home() {
|
||
const { t, lang } = useLang();
|
||
const { data: allCats } = useCategories();
|
||
const { data: trending } = useProducts({ featured: "trending", limit: 10 });
|
||
const { data: bestsellers } = useProducts({
|
||
featured: "bestseller",
|
||
limit: 10,
|
||
});
|
||
const { data: newArr } = useProducts({ featured: "new_arrivals", limit: 10 });
|
||
const { data: topRated } = useProducts({ featured: "top_rated", limit: 10 });
|
||
const { data: s } = useStoreSettings();
|
||
|
||
const extraCats =
|
||
allCats?.filter((c) => !c.source || c.source === "extra") ?? [];
|
||
const sheinCats =
|
||
allCats?.filter((c) => c.source === "shein" && !c.parent_id) ?? [];
|
||
|
||
const accent = s?.hero_accent_color || "#f97316";
|
||
const isEn = lang === "en";
|
||
const heroBadge = isEn
|
||
? s?.hero_badge_en || t("hero_badge")
|
||
: s?.hero_badge_ar || t("hero_badge");
|
||
const heroTitle = isEn
|
||
? s?.hero_title_en || t("hero_title")
|
||
: s?.hero_title_ar || t("hero_title");
|
||
const heroSub = isEn
|
||
? s?.hero_subtitle_en || t("hero_sub")
|
||
: s?.hero_subtitle_ar || t("hero_sub");
|
||
const heroCta = isEn
|
||
? s?.hero_cta_en || t("hero_cta")
|
||
: s?.hero_cta_ar || t("hero_cta");
|
||
const heroCtaLink = s?.hero_cta_link || "/category/0";
|
||
const heroBgImage = s?.hero_bg_image || "";
|
||
|
||
// Promo banners
|
||
let promoBanners: { image_url: string; link: string; title: string }[] = [];
|
||
try {
|
||
promoBanners = JSON.parse(s?.promo_banners || "[]");
|
||
} catch {}
|
||
|
||
const sections = [
|
||
{
|
||
id: "trending",
|
||
enabled: s?.section_trending_enabled !== "false",
|
||
title: isEn
|
||
? s?.section_trending_title_en || t("section_trending_title")
|
||
: s?.section_trending_title_ar || t("section_trending_title"),
|
||
icon: s?.section_trending_icon || "🔥",
|
||
data: trending?.products,
|
||
},
|
||
{
|
||
id: "bestseller",
|
||
enabled: s?.section_bestseller_enabled !== "false",
|
||
title: isEn
|
||
? s?.section_bestseller_title_en || t("section_bestseller_title")
|
||
: s?.section_bestseller_title_ar || t("section_bestseller_title"),
|
||
icon: s?.section_bestseller_icon || "⭐",
|
||
data: bestsellers?.products,
|
||
},
|
||
{
|
||
id: "new",
|
||
enabled: s?.section_new_enabled !== "false",
|
||
title: isEn
|
||
? s?.section_new_title_en || t("section_new_title")
|
||
: s?.section_new_title_ar || t("section_new_title"),
|
||
icon: s?.section_new_icon || "✨",
|
||
data: newArr?.products,
|
||
},
|
||
{
|
||
id: "top-rated",
|
||
enabled: s?.section_top_rated_enabled !== "false",
|
||
title: isEn
|
||
? s?.section_top_rated_title_en || t("section_top_rated_title")
|
||
: s?.section_top_rated_title_ar || t("section_top_rated_title"),
|
||
icon: s?.section_top_rated_icon || "🏆",
|
||
data: topRated?.products,
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div>
|
||
{/* Hero */}
|
||
{s?.hero_enabled !== "false" && (
|
||
<div
|
||
className="relative border-b border-white/8"
|
||
style={
|
||
heroBgImage
|
||
? {
|
||
backgroundImage: `url(${heroBgImage})`,
|
||
backgroundSize: "cover",
|
||
backgroundPosition: "center",
|
||
}
|
||
: {
|
||
background: `linear-gradient(135deg, ${accent}22 0%, #0d0d0d 60%, #0d0d0d 100%)`,
|
||
}
|
||
}
|
||
>
|
||
{heroBgImage && <div className="absolute inset-0 bg-black/60" />}
|
||
<div className="relative max-w-screen-xl mx-auto px-4 py-14 flex flex-col items-center text-center">
|
||
<div
|
||
className="inline-flex items-center gap-2 border text-sm px-4 py-1.5 rounded-full mb-6"
|
||
style={{
|
||
backgroundColor: `${accent}22`,
|
||
borderColor: `${accent}55`,
|
||
color: accent,
|
||
}}
|
||
>
|
||
<span>{heroBadge}</span>
|
||
</div>
|
||
<h1 className="text-4xl md:text-5xl font-black text-white mb-4 leading-tight whitespace-pre-line">
|
||
{heroTitle}
|
||
</h1>
|
||
<p className="text-white/50 text-lg mb-8 max-w-xl">{heroSub}</p>
|
||
<Link
|
||
href={heroCtaLink}
|
||
className="font-bold px-8 py-3 rounded-xl transition-colors text-lg text-white"
|
||
style={{ backgroundColor: accent }}
|
||
>
|
||
{heroCta}
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Promo Banners */}
|
||
{promoBanners.length > 0 && (
|
||
<div className="max-w-screen-xl mx-auto px-4 pt-8">
|
||
<div
|
||
className={`grid gap-4 ${promoBanners.length === 1 ? "grid-cols-1" : promoBanners.length === 2 ? "grid-cols-2" : "grid-cols-3"}`}
|
||
>
|
||
{promoBanners.map((b, i) => (
|
||
<Link key={i} href={b.link || "/"}>
|
||
<div className="relative rounded-2xl overflow-hidden aspect-[3/1] cursor-pointer group">
|
||
<img
|
||
src={b.image_url}
|
||
alt={b.title}
|
||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||
onError={(e) => {
|
||
(
|
||
e.target as HTMLImageElement
|
||
).parentElement!.style.display = "none";
|
||
}}
|
||
/>
|
||
{b.title && (
|
||
<div className="absolute inset-0 bg-gradient-to-l from-black/60 to-transparent flex items-end p-4">
|
||
<span className="text-white font-bold text-sm">
|
||
{b.title}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="max-w-screen-xl mx-auto px-4 py-10">
|
||
{/* eXtra Categories Grid */}
|
||
{s?.extra_section_enabled !== "false" && extraCats.length > 0 && (
|
||
<section className="mb-10">
|
||
<h2 className="text-xl font-bold text-white mb-5 flex items-center gap-2">
|
||
<div
|
||
className="w-5 h-5 rounded flex items-center justify-center shrink-0"
|
||
style={{ backgroundColor: accent }}
|
||
>
|
||
<span className="text-white font-black text-xs">X</span>
|
||
</div>
|
||
{isEn
|
||
? s?.extra_section_title_en || t("section_extra_title")
|
||
: s?.extra_section_title_ar || t("section_extra_title")}
|
||
</h2>
|
||
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-8 gap-3">
|
||
{extraCats.map((c) => (
|
||
<Link key={c.id} href={`/category/${c.id}`}>
|
||
<div className="bg-[#111] border border-white/8 rounded-xl p-3 flex flex-col items-center gap-2 hover:border-orange-500/40 hover:bg-orange-500/8 transition-all cursor-pointer text-center">
|
||
<span className="text-2xl">{c.icon}</span>
|
||
<span className="text-xs text-white/70 font-medium leading-tight">
|
||
{lang === "en" && c.name_en ? c.name_en : c.name}
|
||
</span>
|
||
</div>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* Shein Categories */}
|
||
{s?.shein_section_enabled !== "false" && sheinCats.length > 0 && (
|
||
<section className="mb-14">
|
||
<div className="flex items-center justify-between mb-6">
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex items-center gap-1.5 bg-black border-2 border-white px-3 py-1 rounded">
|
||
<span className="text-white font-black tracking-widest text-sm uppercase">
|
||
SHEIN
|
||
</span>
|
||
</div>
|
||
<div>
|
||
<p className="text-white font-bold text-base leading-tight">
|
||
{isEn
|
||
? s?.shein_section_title_en || t("shein_section_title")
|
||
: s?.shein_section_title_ar || t("shein_section_title")}
|
||
</p>
|
||
<p className="text-white/40 text-xs">
|
||
{isEn ? "أزياء، جمال ومنزل" : "Fashion, Beauty & Home"}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<Link
|
||
href="/category/60"
|
||
className="text-xs text-white/50 hover:text-white border border-white/15 hover:border-white/40 px-3 py-1.5 rounded-full transition-all"
|
||
>
|
||
{t("view_all")}
|
||
</Link>
|
||
</div>
|
||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2.5">
|
||
{sheinCats.map((c) => (
|
||
<Link key={c.id} href={`/category/${c.id}`}>
|
||
<div
|
||
className="relative rounded-lg overflow-hidden cursor-pointer group"
|
||
style={{ aspectRatio: "2/3" }}
|
||
>
|
||
{c.image_url ? (
|
||
<img
|
||
src={c.image_url}
|
||
alt={c.name}
|
||
className="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||
onError={(e) => {
|
||
(e.target as HTMLImageElement).style.display = "none";
|
||
}}
|
||
/>
|
||
) : (
|
||
<div className="absolute inset-0 bg-[#1a1a1a] flex items-center justify-center">
|
||
<span className="text-4xl">{c.icon ?? "🏷️"}</span>
|
||
</div>
|
||
)}
|
||
<div className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/10 to-transparent group-hover:from-black/85 transition-all" />
|
||
<div className="absolute bottom-0 inset-x-0 p-2.5 text-center">
|
||
<span className="text-white text-xs sm:text-sm font-bold leading-tight drop-shadow-lg block">
|
||
{isEn && c.name_en ? c.name_en : c.name}
|
||
</span>
|
||
{c.name_en && !isEn && (
|
||
<span className="text-white/60 text-[9px] sm:text-[10px] block mt-0.5 leading-tight">
|
||
{c.name_en}
|
||
</span>
|
||
)}
|
||
</div>
|
||
{c.slug === "new-in" && (
|
||
<div className="absolute top-2 right-2 bg-white text-black text-[9px] font-black px-1.5 py-0.5 rounded uppercase tracking-wide">
|
||
NEW
|
||
</div>
|
||
)}
|
||
{c.slug === "sale" && (
|
||
<div className="absolute top-2 right-2 bg-red-500 text-white text-[9px] font-black px-1.5 py-0.5 rounded uppercase tracking-wide">
|
||
SALE
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* Product Sections */}
|
||
{sections
|
||
.filter((sec) => sec.enabled && sec.data && sec.data.length > 0)
|
||
.map((sec) => (
|
||
<section
|
||
key={sec.id}
|
||
id={`section-${sec.id}`}
|
||
className="mb-12 scroll-mt-44"
|
||
>
|
||
<div className="flex items-center justify-between mb-5">
|
||
<div>
|
||
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||
<span>{sec.icon}</span> {sec.title}
|
||
</h2>
|
||
</div>
|
||
<Link
|
||
href="/category/0"
|
||
className="text-sm hover:opacity-80 transition-colors"
|
||
style={{ color: accent }}
|
||
>
|
||
{t("section_view_all")}
|
||
</Link>
|
||
</div>
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||
{sec.data!.map((p) => (
|
||
<ProductCard key={p.id} p={p} />
|
||
))}
|
||
</div>
|
||
</section>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Category() {
|
||
const { t, lang } = useLang();
|
||
const [location] = useLocation();
|
||
const pathParts = location.split("/");
|
||
const catId = parseInt(pathParts[pathParts.length - 1] ?? "0") || 0;
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
const q = urlParams.get("q") || "";
|
||
|
||
const [sort, setSort] = useState("relevance");
|
||
const [tab, setTab] = useState<
|
||
"all" | "trending" | "bestseller" | "new_arrivals" | "top_rated"
|
||
>("all");
|
||
const [selectedSubcat, setSelectedSubcat] = useState<string | null>(null);
|
||
|
||
const { data: cats } = useCategories();
|
||
const { data: tree } = useCategoryTree();
|
||
const cat = cats?.find((c) => c.id === catId);
|
||
|
||
const subcats: Category[] =
|
||
catId > 0 ? (tree?.find((n) => n.id === catId)?.children ?? []) : [];
|
||
|
||
const queryParams: Record<string, string | number | undefined> = {
|
||
page: 1,
|
||
limit: 60,
|
||
...(catId > 0 ? { category_id: catId } : {}),
|
||
...(q ? { search: q } : {}),
|
||
...(tab !== "all" ? { featured: tab } : {}),
|
||
...(selectedSubcat ? { subcategory: selectedSubcat } : {}),
|
||
};
|
||
|
||
const { data, isLoading } = useProducts(queryParams);
|
||
const products = data?.products ?? [];
|
||
|
||
const sorted = [...products].sort((a, b) => {
|
||
if (sort === "price_asc") return parseFloat(a.price) - parseFloat(b.price);
|
||
if (sort === "price_desc") return parseFloat(b.price) - parseFloat(a.price);
|
||
if (sort === "rating") return parseFloat(b.rating) - parseFloat(a.rating);
|
||
return 0;
|
||
});
|
||
|
||
return (
|
||
<div className="max-w-screen-xl mx-auto px-4 py-8">
|
||
{/* Back + Breadcrumb */}
|
||
<div className="flex items-center gap-3 text-sm text-white/40 mb-5">
|
||
<button
|
||
onClick={() => window.history.back()}
|
||
className="flex items-center gap-1.5 text-white/50 hover:text-orange-400 transition-colors bg-white/5 hover:bg-orange-500/10 border border-white/10 hover:border-orange-500/30 rounded-xl px-3 py-1.5 shrink-0"
|
||
>
|
||
<svg
|
||
className="w-4 h-4"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M15 19l-7-7 7-7"
|
||
/>
|
||
</svg>
|
||
{t("back")}
|
||
</button>
|
||
<Link href="/" className="hover:text-orange-400 transition-colors">
|
||
{t("home")}
|
||
</Link>
|
||
<span>›</span>
|
||
<span className="text-white/70">
|
||
{catId === 0
|
||
? t("all_products")
|
||
: lang === "en" && cat?.name_en
|
||
? cat.name_en
|
||
: (cat?.name ?? "...")}
|
||
</span>
|
||
{selectedSubcat && (
|
||
<>
|
||
<span>›</span>
|
||
<span className="text-orange-400">{selectedSubcat}</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Title */}
|
||
<div className="mb-5">
|
||
<h1 className="text-2xl font-black text-white flex items-center gap-2">
|
||
{cat?.icon}{" "}
|
||
{catId === 0
|
||
? t("all_products")
|
||
: lang === "en" && cat?.name_en
|
||
? cat.name_en
|
||
: (cat?.name ?? "")}
|
||
{q && (
|
||
<span className="text-base text-white/40 font-normal">
|
||
{t("results_for")} "{q}"
|
||
</span>
|
||
)}
|
||
</h1>
|
||
<p className="text-white/40 text-sm mt-1">
|
||
{data?.total ?? 0} {t("products_count")}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Tabs */}
|
||
<div className="flex items-center gap-2 mb-6 overflow-x-auto pb-2">
|
||
{(
|
||
[
|
||
{ id: "all", label: t("tab_all") },
|
||
{ id: "trending", label: t("tab_trending") },
|
||
{ id: "bestseller", label: t("tab_bestseller") },
|
||
{ id: "top_rated", label: t("tab_top_rated") },
|
||
{ id: "new_arrivals", label: t("tab_new") },
|
||
] as const
|
||
).map((tb) => (
|
||
<button
|
||
key={tb.id}
|
||
onClick={() => setTab(tb.id)}
|
||
className={`shrink-0 px-3 py-2 rounded-lg text-sm font-medium transition-all ${
|
||
tab === tb.id
|
||
? "bg-orange-500 text-white"
|
||
: "bg-white/8 text-white/60 hover:bg-white/12"
|
||
}`}
|
||
>
|
||
{tb.label}
|
||
</button>
|
||
))}
|
||
<div className="mr-auto shrink-0">
|
||
<select
|
||
value={sort}
|
||
onChange={(e) => setSort(e.target.value)}
|
||
className="bg-white/8 border border-white/12 text-white/70 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-orange-500/50"
|
||
>
|
||
<option value="relevance">{t("sort_relevance")}</option>
|
||
<option value="price_asc">{t("sort_price_asc")}</option>
|
||
<option value="price_desc">{t("sort_price_desc")}</option>
|
||
<option value="rating">{t("sort_rating")}</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Mobile: horizontal subcategory chips */}
|
||
{subcats.length > 0 && (
|
||
<div
|
||
className="flex gap-2 mb-4 overflow-x-auto pb-2 md:hidden"
|
||
style={{ scrollbarWidth: "none" }}
|
||
>
|
||
<button
|
||
onClick={() => setSelectedSubcat(null)}
|
||
className={`shrink-0 px-3 py-1.5 rounded-full text-xs font-bold transition-all min-h-[36px] ${
|
||
!selectedSubcat
|
||
? "bg-orange-500 text-white"
|
||
: "bg-white/8 text-white/60 border border-white/12"
|
||
}`}
|
||
>
|
||
{t("tab_all")}
|
||
</button>
|
||
{subcats.map((sc) => (
|
||
<button
|
||
key={sc.id}
|
||
onClick={() =>
|
||
setSelectedSubcat(sc.name === selectedSubcat ? null : sc.name)
|
||
}
|
||
className={`shrink-0 flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-medium transition-all min-h-[36px] ${
|
||
selectedSubcat === sc.name
|
||
? "bg-orange-500 text-white"
|
||
: "bg-white/8 text-white/60 border border-white/12"
|
||
}`}
|
||
>
|
||
<span>{sc.icon}</span>
|
||
<span className="whitespace-nowrap">
|
||
{lang === "en" && sc.name_en ? sc.name_en : sc.name}
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex gap-6">
|
||
{/* Desktop: Subcategory Sidebar */}
|
||
{subcats.length > 0 && (
|
||
<aside className="hidden md:block w-52 shrink-0">
|
||
<div className="bg-[#111] border border-white/8 rounded-xl p-4 sticky top-40">
|
||
<h3 className="text-white font-bold text-sm mb-3 flex items-center gap-2">
|
||
<svg
|
||
className="w-4 h-4 text-orange-400"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M4 6h16M4 12h8m-8 6h16"
|
||
/>
|
||
</svg>
|
||
{t("filter_type")}
|
||
</h3>
|
||
<ul className="space-y-1">
|
||
<li>
|
||
<button
|
||
onClick={() => setSelectedSubcat(null)}
|
||
className={`w-full text-right px-3 py-2 rounded-lg text-sm transition-all ${
|
||
!selectedSubcat
|
||
? "bg-orange-500/15 text-orange-400 border border-orange-500/30"
|
||
: "text-white/60 hover:bg-white/8 hover:text-white"
|
||
}`}
|
||
>
|
||
{t("tab_all")}
|
||
</button>
|
||
</li>
|
||
{subcats.map((sc) => (
|
||
<li key={sc.id}>
|
||
<button
|
||
onClick={() =>
|
||
setSelectedSubcat(
|
||
sc.name === selectedSubcat ? null : sc.name,
|
||
)
|
||
}
|
||
className={`w-full text-right px-3 py-2 rounded-lg text-sm transition-all flex items-center gap-2 ${
|
||
selectedSubcat === sc.name
|
||
? "bg-orange-500/15 text-orange-400 border border-orange-500/30"
|
||
: "text-white/60 hover:bg-white/8 hover:text-white"
|
||
}`}
|
||
>
|
||
<span>{sc.icon}</span>
|
||
<span className="flex-1">
|
||
{lang === "en" && sc.name_en ? sc.name_en : sc.name}
|
||
</span>
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
</aside>
|
||
)}
|
||
|
||
{/* Products Grid */}
|
||
<div className="flex-1 min-w-0">
|
||
{isLoading ? (
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3 md:gap-4">
|
||
{[...Array(12)].map((_, i) => (
|
||
<div
|
||
key={i}
|
||
className="bg-[#111] border border-white/8 rounded-xl overflow-hidden animate-pulse"
|
||
>
|
||
<div className="aspect-square bg-white/5" />
|
||
<div className="p-3 space-y-2">
|
||
<div className="h-3 bg-white/5 rounded w-1/2" />
|
||
<div className="h-4 bg-white/5 rounded" />
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : sorted.length === 0 ? (
|
||
<div className="text-center py-20 text-white/40">
|
||
<div className="text-5xl mb-4">🔍</div>
|
||
<p className="text-lg">{t("no_products")}</p>
|
||
{selectedSubcat && (
|
||
<button
|
||
onClick={() => setSelectedSubcat(null)}
|
||
className="mt-3 text-orange-400 text-sm hover:underline"
|
||
>
|
||
{t("remove_filter")}
|
||
</button>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div
|
||
className={`grid gap-3 md:gap-4 ${subcats.length > 0 ? "grid-cols-2 sm:grid-cols-3 md:grid-cols-3 lg:grid-cols-4" : "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5"}`}
|
||
>
|
||
{sorted.map((p) => (
|
||
<ProductCard key={p.id} p={p} />
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ProductPage() {
|
||
const { t, lang } = useLang();
|
||
const [location, navigate] = useLocation();
|
||
const id = parseInt(location.split("/").pop() ?? "0") || 0;
|
||
const { data: p, isLoading } = useProduct(id);
|
||
const [qty, setQty] = useState(1);
|
||
const [selColor, setSelColor] = useState<string | null>(null);
|
||
const [selSize, setSelSize] = useState<string | null>(null);
|
||
const [imgLoaded, setImgLoaded] = useState(false);
|
||
const [addedToBag, setAddedToBag] = useState(false);
|
||
const { addItem } = useCart();
|
||
const showToast = useShowToast();
|
||
|
||
useEffect(() => {
|
||
if (p?.colors?.[0]) setSelColor(p.colors[0]);
|
||
}, [p]);
|
||
useEffect(() => {
|
||
if (p?.sizes?.[0]) setSelSize(p.sizes[0]);
|
||
}, [p]);
|
||
|
||
if (isLoading)
|
||
return (
|
||
<div className="max-w-screen-xl mx-auto px-4 py-12 flex justify-center">
|
||
<div className="w-10 h-10 border-2 border-orange-500/30 border-t-orange-500 rounded-full animate-spin" />
|
||
</div>
|
||
);
|
||
if (!p)
|
||
return (
|
||
<div className="text-center py-20 text-white/40">
|
||
{t("product_not_found")}
|
||
</div>
|
||
);
|
||
|
||
const discount = p.original_price
|
||
? Math.round((1 - parseFloat(p.price) / parseFloat(p.original_price)) * 100)
|
||
: 0;
|
||
const img = (Array.isArray(p.images) && p.images[0]) || "";
|
||
const displayName = lang === "en" && p.name_en ? p.name_en : p.name;
|
||
|
||
return (
|
||
<div className="max-w-screen-xl mx-auto px-3 sm:px-4 py-5 sm:py-8">
|
||
{/* Back + Breadcrumb */}
|
||
<div className="flex items-center gap-2 text-xs sm:text-sm text-white/40 mb-4 sm:mb-6 flex-wrap">
|
||
<button
|
||
onClick={() => window.history.back()}
|
||
className="flex items-center gap-1 text-white/50 hover:text-orange-400 transition-colors bg-white/5 hover:bg-orange-500/10 border border-white/10 hover:border-orange-500/30 rounded-xl px-3 py-1.5 shrink-0"
|
||
>
|
||
<svg
|
||
className="w-3.5 h-3.5"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M15 19l-7-7 7-7"
|
||
/>
|
||
</svg>
|
||
{t("back")}
|
||
</button>
|
||
<Link href="/" className="hover:text-orange-400">
|
||
{t("home")}
|
||
</Link>
|
||
<span>›</span>
|
||
<Link
|
||
href={`/category/${p.category_id}`}
|
||
className="hover:text-orange-400"
|
||
>
|
||
{t("category_link")}
|
||
</Link>
|
||
<span>›</span>
|
||
<span className="text-white/60 line-clamp-1">{displayName}</span>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5 sm:gap-10">
|
||
{/* Image — white background like Extra store */}
|
||
<div className="bg-white rounded-2xl p-5 sm:p-8 flex items-center justify-center aspect-square relative overflow-hidden shadow-sm max-h-80 sm:max-h-none">
|
||
{discount > 0 && (
|
||
<div className="absolute top-4 right-4 bg-red-500 text-white font-bold text-sm px-3 py-1 rounded-lg z-10 shadow">
|
||
{t("save_percent")} {discount}%
|
||
</div>
|
||
)}
|
||
{img && (
|
||
<img
|
||
src={proxyImg(img)}
|
||
alt={p.name}
|
||
onLoad={() => setImgLoaded(true)}
|
||
className={`max-w-full max-h-full object-contain transition-opacity duration-500 ${imgLoaded ? "opacity-100" : "opacity-0"}`}
|
||
/>
|
||
)}
|
||
{!imgLoaded && img && (
|
||
<div className="absolute inset-0 flex items-center justify-center bg-white">
|
||
<div className="w-10 h-10 border-2 border-gray-200 border-t-orange-400 rounded-full animate-spin" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Details */}
|
||
<div className="flex flex-col">
|
||
{p.brand && (
|
||
<span className="text-orange-400 font-bold text-sm tracking-wider mb-1">
|
||
{p.brand}
|
||
</span>
|
||
)}
|
||
<h1 className="text-2xl font-bold text-white mb-3 leading-snug">
|
||
{displayName}
|
||
</h1>
|
||
|
||
<StarRating rating={p.rating} count={p.review_count} />
|
||
|
||
{/* Price */}
|
||
<div className="mt-5 p-4 bg-[#111] border border-white/8 rounded-xl">
|
||
<div className="flex items-baseline gap-3">
|
||
<span className="text-3xl font-black text-orange-400">
|
||
{parseFloat(p.price).toLocaleString("ar-SA")} {t("currency")}
|
||
</span>
|
||
{p.original_price &&
|
||
parseFloat(p.original_price) > parseFloat(p.price) && (
|
||
<span className="text-white/35 text-lg line-through">
|
||
{parseFloat(p.original_price).toLocaleString("ar-SA")}
|
||
</span>
|
||
)}
|
||
{discount > 0 && (
|
||
<span className="bg-red-500/20 text-red-400 text-sm font-bold px-2 py-0.5 rounded">
|
||
{t("save_percent")} {discount}%
|
||
</span>
|
||
)}
|
||
</div>
|
||
{p.original_price &&
|
||
parseFloat(p.original_price) > parseFloat(p.price) && (
|
||
<p className="text-emerald-400 text-sm mt-1">
|
||
{t("saving")}{" "}
|
||
{(
|
||
parseFloat(p.original_price) - parseFloat(p.price)
|
||
).toLocaleString("ar-SA")}{" "}
|
||
{t("currency")}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Colors */}
|
||
{(p.colors?.length ?? 0) > 0 && (
|
||
<div className="mt-5">
|
||
<p className="text-sm text-white/60 mb-2">
|
||
{t("color_label")}{" "}
|
||
<span className="text-white font-medium">{selColor}</span>
|
||
</p>
|
||
<div className="flex flex-wrap gap-2">
|
||
{(p.colors ?? []).map((c) => (
|
||
<button
|
||
key={c}
|
||
onClick={() => setSelColor(c)}
|
||
className={`px-3 py-1.5 rounded-lg text-xs border transition-all ${
|
||
selColor === c
|
||
? "border-orange-500 bg-orange-500/15 text-orange-400"
|
||
: "border-white/15 text-white/60 hover:border-white/30"
|
||
}`}
|
||
>
|
||
{c}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Sizes */}
|
||
{(p.sizes?.length ?? 0) > 0 && (
|
||
<div className="mt-4">
|
||
<p className="text-sm text-white/60 mb-2">
|
||
{t("size_label")}{" "}
|
||
<span className="text-white font-medium">{selSize}</span>
|
||
</p>
|
||
<div className="flex flex-wrap gap-2">
|
||
{(p.sizes ?? []).map((s) => (
|
||
<button
|
||
key={s}
|
||
onClick={() => setSelSize(s)}
|
||
className={`px-3 py-1.5 rounded-lg text-xs border transition-all ${
|
||
selSize === s
|
||
? "border-orange-500 bg-orange-500/15 text-orange-400"
|
||
: "border-white/15 text-white/60 hover:border-white/30"
|
||
}`}
|
||
>
|
||
{s}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Qty */}
|
||
<div className="mt-5 flex items-center gap-3">
|
||
<span className="text-sm text-white/60">{t("qty_label")}</span>
|
||
<div className="flex items-center bg-[#111] border border-white/12 rounded-xl overflow-hidden">
|
||
<button
|
||
onClick={() => setQty((q) => Math.max(1, q - 1))}
|
||
className="px-4 py-2 text-white/60 hover:text-white hover:bg-white/8 transition-colors text-lg"
|
||
>
|
||
-
|
||
</button>
|
||
<span className="px-4 py-2 text-white font-bold min-w-[3rem] text-center">
|
||
{qty}
|
||
</span>
|
||
<button
|
||
onClick={() => setQty((q) => Math.min(p.stock ?? 99, q + 1))}
|
||
className="px-4 py-2 text-white/60 hover:text-white hover:bg-white/8 transition-colors text-lg"
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
{(p.stock ?? 0) > 0 && (
|
||
<span className="text-xs text-white/35">
|
||
{p.stock} {t("available")}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* CTA */}
|
||
<div className="mt-6 flex gap-3">
|
||
<button
|
||
onClick={() => {
|
||
if (!p) return;
|
||
addItem(p, qty, selColor, selSize);
|
||
showToast(t("added_toast"));
|
||
setAddedToBag(true);
|
||
setTimeout(() => setAddedToBag(false), 2500);
|
||
}}
|
||
className={`flex-1 font-bold py-3.5 rounded-xl transition-all text-base flex items-center justify-center gap-2 ${
|
||
addedToBag
|
||
? "bg-emerald-500 text-white"
|
||
: "bg-orange-500 hover:bg-orange-600 text-white"
|
||
}`}
|
||
>
|
||
{addedToBag ? t("added_to_cart") : t("add_to_cart")}
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
if (p) {
|
||
addItem(p, qty, selColor, selSize);
|
||
navigate("/cart");
|
||
}
|
||
}}
|
||
className="px-5 py-3.5 bg-white/8 hover:bg-white/15 border border-white/15 text-white font-bold rounded-xl transition-all text-sm whitespace-nowrap"
|
||
>
|
||
{t("buy_now")}
|
||
</button>
|
||
<button className="w-14 h-14 border border-white/15 hover:border-red-400/40 hover:bg-red-400/10 rounded-xl transition-all flex items-center justify-center text-xl">
|
||
♡
|
||
</button>
|
||
</div>
|
||
|
||
{/* Badges */}
|
||
<div className="mt-5 grid grid-cols-3 gap-2">
|
||
{(
|
||
[
|
||
["🚚", t("badge_fast")],
|
||
["🛡️", t("badge_auth")],
|
||
["↩️", t("badge_return")],
|
||
] as [string, string][]
|
||
).map(([icon, label]) => (
|
||
<div
|
||
key={label}
|
||
className="flex flex-col items-center gap-1 bg-white/4 rounded-xl p-3 text-center"
|
||
>
|
||
<span className="text-xl">{icon}</span>
|
||
<span className="text-xs text-white/50">{label}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Marketing Points */}
|
||
{(p.marketing_points?.length ?? 0) > 0 && (
|
||
<div className="mt-10 bg-[#0d0d0d] border border-white/8 rounded-2xl p-6">
|
||
<h2 className="text-lg font-bold text-white mb-4">
|
||
{t("product_features")}
|
||
</h2>
|
||
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||
{(p.marketing_points ?? []).map((pt, i) => (
|
||
<li
|
||
key={i}
|
||
className="flex items-start gap-2.5 text-sm text-white/70"
|
||
>
|
||
<span className="text-orange-500 mt-0.5 shrink-0">✓</span>
|
||
{pt}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
|
||
{/* Specs */}
|
||
{p.specs && Object.keys(p.specs).length > 0 && (
|
||
<div className="mt-6 bg-[#0d0d0d] border border-white/8 rounded-2xl overflow-hidden">
|
||
<h2 className="text-lg font-bold text-white p-6 pb-4">
|
||
{t("tech_specs")}
|
||
</h2>
|
||
<table className="w-full text-sm">
|
||
<tbody>
|
||
{Object.entries(p.specs).map(([key, val], i) => (
|
||
<tr key={key} className={i % 2 === 0 ? "bg-white/2" : ""}>
|
||
<td className="px-6 py-3 text-white/50 font-medium w-2/5">
|
||
{key}
|
||
</td>
|
||
<td className="px-6 py-3 text-white/85">{String(val)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Cart Page ───────────────────────────────────────
|
||
function CartPage() {
|
||
const { t, lang } = useLang();
|
||
const { data: storeSettings } = useStoreSettings();
|
||
const { items, removeItem, updateQty, clearCart, subtotal } = useCart();
|
||
const [, navigate] = useLocation();
|
||
const { user, openAuth } = useAuth();
|
||
const VAT_RATE = 0.15;
|
||
const freeShipRiyadh = parseFloat(
|
||
storeSettings?.cart_free_shipping_riyadh || "299",
|
||
);
|
||
const feeRiyadh = parseFloat(storeSettings?.cart_delivery_fee_riyadh || "19");
|
||
const SHIPPING = subtotal >= freeShipRiyadh ? 0 : feeRiyadh;
|
||
const vat = subtotal * VAT_RATE;
|
||
const total = subtotal + vat + SHIPPING;
|
||
const cartPageTitle = storeCopy(
|
||
storeSettings,
|
||
lang,
|
||
"cart_page_title",
|
||
t("cart_title"),
|
||
);
|
||
const cartPageSubtitle = storeCopy(
|
||
storeSettings,
|
||
lang,
|
||
"cart_page_subtitle",
|
||
`${items.reduce((s, i) => s + i.quantity, 0)} ${t("products_count")}`,
|
||
);
|
||
const cartCheckoutLabel = storeCopy(
|
||
storeSettings,
|
||
lang,
|
||
"cart_checkout_button",
|
||
t("cart_checkout"),
|
||
);
|
||
const cartSecureLabel = storeCopy(
|
||
storeSettings,
|
||
lang,
|
||
"cart_secure_label",
|
||
t("cart_secure"),
|
||
);
|
||
const cartCheckoutNote = storeCopy(
|
||
storeSettings,
|
||
lang,
|
||
"cart_checkout_note",
|
||
"",
|
||
);
|
||
const activePaymentMethods = [
|
||
storeSettings?.cart_payment_visa !== "false" ? "Visa / Mastercard" : null,
|
||
storeSettings?.cart_payment_mada !== "false"
|
||
? lang === "en"
|
||
? "mada"
|
||
: "مدى"
|
||
: null,
|
||
storeSettings?.cart_payment_applepay !== "false" ? "Apple Pay" : null,
|
||
storeSettings?.cart_payment_stcpay !== "false" ? "STC Pay" : null,
|
||
].filter(Boolean) as string[];
|
||
|
||
if (items.length === 0) {
|
||
return (
|
||
<div className="max-w-screen-xl mx-auto px-4 py-8 text-center">
|
||
<div className="flex mb-8">
|
||
<button
|
||
onClick={() => window.history.back()}
|
||
className="flex items-center gap-1.5 text-white/50 hover:text-orange-400 transition-colors bg-white/5 hover:bg-orange-500/10 border border-white/10 hover:border-orange-500/30 rounded-xl px-3 py-1.5 text-sm"
|
||
>
|
||
<svg
|
||
className="w-4 h-4"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M15 19l-7-7 7-7"
|
||
/>
|
||
</svg>
|
||
{t("back")}
|
||
</button>
|
||
</div>
|
||
<div className="inline-flex items-center justify-center w-24 h-24 rounded-full bg-white/4 border border-white/10 mb-8">
|
||
<svg
|
||
className="w-12 h-12 text-white/25"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={1.5}
|
||
d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z"
|
||
/>
|
||
</svg>
|
||
</div>
|
||
<h1 className="text-2xl font-black text-white mb-2">
|
||
{t("cart_empty_title")}
|
||
</h1>
|
||
<p className="text-white/40 mb-8">{t("cart_empty_sub")}</p>
|
||
<Link
|
||
href="/category/0"
|
||
className="bg-orange-500 hover:bg-orange-600 text-white font-bold px-8 py-3 rounded-xl transition-colors inline-block"
|
||
>
|
||
{t("cart_shop_now")}
|
||
</Link>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="max-w-screen-xl mx-auto px-4 py-8">
|
||
{storeSettings?.cart_banner_enabled !== "false" && (
|
||
<div
|
||
className="mb-6 rounded-2xl border border-white/10 px-4 py-3 text-sm font-medium"
|
||
style={{
|
||
backgroundColor: storeSettings?.cart_banner_color || "#1f2937",
|
||
color: storeSettings?.cart_banner_text_color || "#ffffff",
|
||
}}
|
||
>
|
||
{storeSettings?.cart_banner_text || ""}
|
||
</div>
|
||
)}
|
||
{/* Back button */}
|
||
<button
|
||
onClick={() => window.history.back()}
|
||
className="flex items-center gap-1.5 text-white/50 hover:text-orange-400 transition-colors bg-white/5 hover:bg-orange-500/10 border border-white/10 hover:border-orange-500/30 rounded-xl px-3 py-1.5 text-sm mb-6"
|
||
>
|
||
<svg
|
||
className="w-4 h-4"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M15 19l-7-7 7-7"
|
||
/>
|
||
</svg>
|
||
{t("back")}
|
||
</button>
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between mb-8">
|
||
<div>
|
||
<h1 className="text-2xl font-black text-white">{cartPageTitle}</h1>
|
||
<p className="text-white/40 text-sm mt-1">{cartPageSubtitle}</p>
|
||
</div>
|
||
<button
|
||
onClick={clearCart}
|
||
className="text-sm text-white/35 hover:text-red-400 transition-colors flex items-center gap-1.5"
|
||
>
|
||
<svg
|
||
className="w-4 h-4"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||
/>
|
||
</svg>
|
||
{t("cart_clear")}
|
||
</button>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||
{/* Items List */}
|
||
<div className="lg:col-span-2 space-y-4">
|
||
{items.map((item) => {
|
||
const itemKey = `${item.product.id}|${item.color}|${item.size}`;
|
||
const img = item.product.images[0] || "";
|
||
const itemTotal = parseFloat(item.product.price) * item.quantity;
|
||
return (
|
||
<div
|
||
key={itemKey}
|
||
className="bg-[#111] border border-white/8 rounded-2xl p-3 sm:p-5 flex gap-3 sm:gap-5 hover:border-white/15 transition-colors"
|
||
>
|
||
{/* Image */}
|
||
<Link href={`/product/${item.product.id}`} className="shrink-0">
|
||
<div className="w-20 h-20 sm:w-24 sm:h-24 bg-white rounded-xl overflow-hidden flex items-center justify-center">
|
||
{img ? (
|
||
<img
|
||
src={proxyImg(img)}
|
||
alt={item.product.name}
|
||
className="w-full h-full object-contain p-2"
|
||
/>
|
||
) : (
|
||
<div className="text-gray-300 text-2xl">📦</div>
|
||
)}
|
||
</div>
|
||
</Link>
|
||
|
||
{/* Details */}
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex justify-between items-start gap-3">
|
||
<div className="min-w-0">
|
||
{item.product.brand && (
|
||
<span className="text-orange-400 text-xs font-bold uppercase tracking-wider">
|
||
{item.product.brand}
|
||
</span>
|
||
)}
|
||
<Link href={`/product/${item.product.id}`}>
|
||
<h3 className="text-sm font-medium text-white/90 leading-snug mt-0.5 hover:text-orange-400 transition-colors line-clamp-2">
|
||
{lang === "en" && item.product.name_en
|
||
? item.product.name_en
|
||
: item.product.name}
|
||
</h3>
|
||
</Link>
|
||
{(item.color || item.size) && (
|
||
<div className="flex gap-3 mt-1.5">
|
||
{item.color && (
|
||
<span className="text-xs text-white/40 bg-white/6 px-2 py-0.5 rounded-md">
|
||
{item.color}
|
||
</span>
|
||
)}
|
||
{item.size && (
|
||
<span className="text-xs text-white/40 bg-white/6 px-2 py-0.5 rounded-md">
|
||
{item.size}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<button
|
||
onClick={() =>
|
||
removeItem(item.product.id, item.color, item.size)
|
||
}
|
||
className="shrink-0 flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-white/30 hover:text-red-400 hover:bg-red-400/10 border border-transparent hover:border-red-400/20 transition-all text-xs font-medium"
|
||
>
|
||
<svg
|
||
className="w-3.5 h-3.5"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||
/>
|
||
</svg>
|
||
{t("cart_delete")}
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between mt-4">
|
||
{/* Qty Controls */}
|
||
<div className="flex items-center bg-[#0a0a0a] border border-white/10 rounded-xl overflow-hidden">
|
||
<button
|
||
onClick={() =>
|
||
updateQty(
|
||
item.product.id,
|
||
item.color,
|
||
item.size,
|
||
item.quantity - 1,
|
||
)
|
||
}
|
||
className="w-9 h-9 flex items-center justify-center text-white/50 hover:text-white hover:bg-white/8 transition-colors text-lg font-bold"
|
||
>
|
||
−
|
||
</button>
|
||
<span className="w-10 text-center text-white text-sm font-bold">
|
||
{item.quantity}
|
||
</span>
|
||
<button
|
||
onClick={() =>
|
||
updateQty(
|
||
item.product.id,
|
||
item.color,
|
||
item.size,
|
||
Math.min(item.quantity + 1, item.product.stock),
|
||
)
|
||
}
|
||
className="w-9 h-9 flex items-center justify-center text-white/50 hover:text-white hover:bg-white/8 transition-colors text-lg font-bold"
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
|
||
{/* Price */}
|
||
<div className="text-left">
|
||
<div className="text-orange-400 font-black text-base">
|
||
{itemTotal.toLocaleString("ar-SA")} {t("currency")}
|
||
</div>
|
||
{item.quantity > 1 && (
|
||
<div className="text-white/30 text-xs">
|
||
{parseFloat(item.product.price).toLocaleString(
|
||
"ar-SA",
|
||
)}{" "}
|
||
× {item.quantity}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{/* Continue Shopping */}
|
||
<Link
|
||
href="/category/0"
|
||
className="flex items-center gap-2 text-sm text-white/40 hover:text-orange-400 transition-colors mt-2 group"
|
||
>
|
||
<svg
|
||
className="w-4 h-4 group-hover:-translate-x-1 transition-transform"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M9 5l7 7-7 7"
|
||
/>
|
||
</svg>
|
||
{t("cart_continue")}
|
||
</Link>
|
||
</div>
|
||
|
||
{/* Order Summary */}
|
||
<div className="lg:col-span-1">
|
||
<div className="bg-[#111] border border-white/8 rounded-2xl p-6 sticky top-24">
|
||
<h2 className="text-lg font-black text-white mb-6 pb-4 border-b border-white/8">
|
||
{t("cart_summary")}
|
||
</h2>
|
||
|
||
<div className="space-y-3.5 text-sm">
|
||
<div className="flex justify-between text-white/60">
|
||
<span>{t("cart_subtotal")}</span>
|
||
<span className="text-white font-medium">
|
||
{subtotal.toLocaleString("ar-SA")} {t("currency")}
|
||
</span>
|
||
</div>
|
||
<div className="flex justify-between text-white/60">
|
||
<span>{t("cart_shipping")}</span>
|
||
{SHIPPING === 0 ? (
|
||
<span className="text-emerald-400 font-medium">
|
||
{t("cart_shipping_free")}
|
||
</span>
|
||
) : (
|
||
<span className="text-white font-medium">
|
||
{SHIPPING.toLocaleString("ar-SA")} {t("currency")}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="flex justify-between text-white/60">
|
||
<span>{t("cart_vat")}</span>
|
||
<span className="text-white font-medium">
|
||
{vat.toFixed(2)} {t("currency")}
|
||
</span>
|
||
</div>
|
||
{SHIPPING > 0 && (
|
||
<div className="text-xs text-orange-400/70 bg-orange-500/8 rounded-lg p-2.5">
|
||
{t("cart_add_for_free")}{" "}
|
||
{Math.max(0, freeShipRiyadh - subtotal).toLocaleString(
|
||
"ar-SA",
|
||
)}{" "}
|
||
{t("cart_for_free_ship")}
|
||
</div>
|
||
)}
|
||
<div className="pt-4 border-t border-white/10 flex justify-between items-baseline">
|
||
<span className="text-white font-bold">{t("cart_total")}</span>
|
||
<div className="text-left">
|
||
<div className="text-2xl font-black text-orange-400">
|
||
{total.toFixed(2)} {t("currency")}
|
||
</div>
|
||
<div className="text-xs text-white/30 text-right">
|
||
{t("cart_total_incl")}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Checkout Button */}
|
||
{user ? (
|
||
<button
|
||
onClick={() => navigate("/checkout")}
|
||
className="w-full mt-6 bg-orange-500 hover:bg-orange-600 text-white font-black py-4 rounded-xl transition-colors text-base shadow-lg shadow-orange-500/20 flex items-center justify-center gap-2"
|
||
>
|
||
<svg
|
||
className="w-5 h-5"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||
/>
|
||
</svg>
|
||
{cartCheckoutLabel}
|
||
</button>
|
||
) : (
|
||
<div className="mt-6 space-y-3">
|
||
<div className="bg-orange-500/8 border border-orange-500/25 rounded-xl p-3.5 flex items-start gap-3">
|
||
<svg
|
||
className="w-5 h-5 text-orange-400 shrink-0 mt-0.5"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||
/>
|
||
</svg>
|
||
<p className="text-sm text-white/70 leading-relaxed">
|
||
{t("cart_login_required")}
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={() => openAuth("login")}
|
||
className="w-full bg-orange-500 hover:bg-orange-600 text-white font-black py-4 rounded-xl transition-colors text-base shadow-lg shadow-orange-500/20 flex items-center justify-center gap-2"
|
||
>
|
||
<svg
|
||
className="w-5 h-5"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||
/>
|
||
</svg>
|
||
{t("cart_login_btn")}
|
||
</button>
|
||
<button
|
||
onClick={() => openAuth("register")}
|
||
className="w-full bg-white/6 hover:bg-white/10 border border-white/12 hover:border-orange-500/40 text-white/80 font-bold py-3 rounded-xl transition-colors text-sm flex items-center justify-center gap-2"
|
||
>
|
||
{t("cart_register_btn")}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Secure Badge */}
|
||
<div className="mt-4 flex items-center justify-center gap-2 text-xs text-white/30">
|
||
<svg
|
||
className="w-3.5 h-3.5"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||
/>
|
||
</svg>
|
||
{cartSecureLabel}
|
||
</div>
|
||
|
||
{/* Payment methods */}
|
||
<div className="mt-4 flex justify-center gap-2 flex-wrap">
|
||
{activePaymentMethods.map((m) => (
|
||
<div
|
||
key={m}
|
||
className="bg-white/6 border border-white/10 rounded-lg px-2 py-1 text-[9px] text-white/40 font-medium"
|
||
>
|
||
{m}
|
||
</div>
|
||
))}
|
||
</div>
|
||
{cartCheckoutNote && (
|
||
<p className="mt-3 text-center text-xs text-white/35 leading-relaxed">
|
||
{cartCheckoutNote}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Checkout Page ───────────────────────────────────
|
||
const CHECKOUT_CITIES: { value: string; label: string; label_en: string }[] = [
|
||
// Riyadh Region
|
||
{
|
||
value: "الرياض",
|
||
label: "الرياض — توصيل 3 أيام عمل",
|
||
label_en: "Riyadh — 3 business days",
|
||
},
|
||
{
|
||
value: "الخرج",
|
||
label: "الخرج — توصيل 5 أيام عمل",
|
||
label_en: "Al Kharj — 5 business days",
|
||
},
|
||
{
|
||
value: "المجمعة",
|
||
label: "المجمعة — توصيل 5 أيام عمل",
|
||
label_en: "Majmaah — 5 business days",
|
||
},
|
||
{
|
||
value: "الزلفي",
|
||
label: "الزلفي — توصيل 5 أيام عمل",
|
||
label_en: "Zulfi — 5 business days",
|
||
},
|
||
{
|
||
value: "القويعية",
|
||
label: "القويعية — توصيل 5 أيام عمل",
|
||
label_en: "Al Quwaiyah — 5 business days",
|
||
},
|
||
{
|
||
value: "الأفلاج",
|
||
label: "الأفلاج — توصيل 7 أيام عمل",
|
||
label_en: "Al Aflaj — 7 business days",
|
||
},
|
||
{
|
||
value: "وادي الدواسر",
|
||
label: "وادي الدواسر — توصيل 7 أيام عمل",
|
||
label_en: "Wadi Ad-Dawasir — 7 business days",
|
||
},
|
||
{
|
||
value: "عفيف",
|
||
label: "عفيف — توصيل 7 أيام عمل",
|
||
label_en: "Afif — 7 business days",
|
||
},
|
||
{
|
||
value: "الدوادمي",
|
||
label: "الدوادمي — توصيل 7 أيام عمل",
|
||
label_en: "Ad Dawadimi — 7 business days",
|
||
},
|
||
{
|
||
value: "شقراء",
|
||
label: "شقراء — توصيل 7 أيام عمل",
|
||
label_en: "Shaqra — 7 business days",
|
||
},
|
||
{
|
||
value: "ضرما",
|
||
label: "ضرما — توصيل 5 أيام عمل",
|
||
label_en: "Dirma — 5 business days",
|
||
},
|
||
{
|
||
value: "المزاحمية",
|
||
label: "المزاحمية — توصيل 5 أيام عمل",
|
||
label_en: "Al Muzahimiyah — 5 business days",
|
||
},
|
||
{
|
||
value: "الحريق",
|
||
label: "الحريق — توصيل 7 أيام عمل",
|
||
label_en: "Al Hariq — 7 business days",
|
||
},
|
||
{
|
||
value: "السليل",
|
||
label: "السليل — توصيل 7 أيام عمل",
|
||
label_en: "As Sulayyil — 7 business days",
|
||
},
|
||
{
|
||
value: "ثادق",
|
||
label: "ثادق — توصيل 7 أيام عمل",
|
||
label_en: "Thadiq — 7 business days",
|
||
},
|
||
{
|
||
value: "رماح",
|
||
label: "رماح — توصيل 7 أيام عمل",
|
||
label_en: "Rumah — 7 business days",
|
||
},
|
||
// Makkah Region
|
||
{
|
||
value: "مكة المكرمة",
|
||
label: "مكة المكرمة — توصيل 5 أيام عمل",
|
||
label_en: "Makkah — 5 business days",
|
||
},
|
||
{
|
||
value: "جدة",
|
||
label: "جدة — توصيل 5 أيام عمل",
|
||
label_en: "Jeddah — 5 business days",
|
||
},
|
||
{
|
||
value: "الطائف",
|
||
label: "الطائف — توصيل 5 أيام عمل",
|
||
label_en: "Taif — 5 business days",
|
||
},
|
||
{
|
||
value: "رابغ",
|
||
label: "رابغ — توصيل 7 أيام عمل",
|
||
label_en: "Rabigh — 7 business days",
|
||
},
|
||
{
|
||
value: "القنفذة",
|
||
label: "القنفذة — توصيل 7 أيام عمل",
|
||
label_en: "Al Qunfudhah — 7 business days",
|
||
},
|
||
{
|
||
value: "الليث",
|
||
label: "الليث — توصيل 7 أيام عمل",
|
||
label_en: "Al Lith — 7 business days",
|
||
},
|
||
{
|
||
value: "خليص",
|
||
label: "خليص — توصيل 7 أيام عمل",
|
||
label_en: "Khulays — 7 business days",
|
||
},
|
||
{
|
||
value: "الجموم",
|
||
label: "الجموم — توصيل 7 أيام عمل",
|
||
label_en: "Al Jumum — 7 business days",
|
||
},
|
||
// Madinah Region
|
||
{
|
||
value: "المدينة المنورة",
|
||
label: "المدينة المنورة — توصيل 5 أيام عمل",
|
||
label_en: "Madinah — 5 business days",
|
||
},
|
||
{
|
||
value: "ينبع",
|
||
label: "ينبع — توصيل 5 أيام عمل",
|
||
label_en: "Yanbu — 5 business days",
|
||
},
|
||
{
|
||
value: "العلا",
|
||
label: "العلا — توصيل 7 أيام عمل",
|
||
label_en: "Al Ula — 7 business days",
|
||
},
|
||
{
|
||
value: "المهد",
|
||
label: "المهد — توصيل 7 أيام عمل",
|
||
label_en: "Al Mahd — 7 business days",
|
||
},
|
||
{
|
||
value: "بدر",
|
||
label: "بدر — توصيل 7 أيام عمل",
|
||
label_en: "Badr — 7 business days",
|
||
},
|
||
{
|
||
value: "خيبر",
|
||
label: "خيبر — توصيل 7 أيام عمل",
|
||
label_en: "Khaybar — 7 business days",
|
||
},
|
||
// Qassim Region
|
||
{
|
||
value: "بريدة",
|
||
label: "بريدة — توصيل 5 أيام عمل",
|
||
label_en: "Buraydah — 5 business days",
|
||
},
|
||
{
|
||
value: "عنيزة",
|
||
label: "عنيزة — توصيل 5 أيام عمل",
|
||
label_en: "Unaizah — 5 business days",
|
||
},
|
||
{
|
||
value: "الرس",
|
||
label: "الرس — توصيل 7 أيام عمل",
|
||
label_en: "Ar Rass — 7 business days",
|
||
},
|
||
{
|
||
value: "المذنب",
|
||
label: "المذنب — توصيل 7 أيام عمل",
|
||
label_en: "Al Mithnab — 7 business days",
|
||
},
|
||
{
|
||
value: "البكيرية",
|
||
label: "البكيرية — توصيل 7 أيام عمل",
|
||
label_en: "Al Bukayriyah — 7 business days",
|
||
},
|
||
{
|
||
value: "البدائع",
|
||
label: "البدائع — توصيل 7 أيام عمل",
|
||
label_en: "Al Badaie — 7 business days",
|
||
},
|
||
// Eastern Region
|
||
{
|
||
value: "الدمام",
|
||
label: "الدمام — توصيل 5 أيام عمل",
|
||
label_en: "Dammam — 5 business days",
|
||
},
|
||
{
|
||
value: "الخبر",
|
||
label: "الخبر — توصيل 5 أيام عمل",
|
||
label_en: "Khobar — 5 business days",
|
||
},
|
||
{
|
||
value: "الأحساء",
|
||
label: "الأحساء — توصيل 5 أيام عمل",
|
||
label_en: "Al Ahsa — 5 business days",
|
||
},
|
||
{
|
||
value: "الظهران",
|
||
label: "الظهران — توصيل 5 أيام عمل",
|
||
label_en: "Dhahran — 5 business days",
|
||
},
|
||
{
|
||
value: "الجبيل",
|
||
label: "الجبيل — توصيل 5 أيام عمل",
|
||
label_en: "Jubail — 5 business days",
|
||
},
|
||
{
|
||
value: "القطيف",
|
||
label: "القطيف — توصيل 5 أيام عمل",
|
||
label_en: "Qatif — 5 business days",
|
||
},
|
||
{
|
||
value: "حفر الباطن",
|
||
label: "حفر الباطن — توصيل 7 أيام عمل",
|
||
label_en: "Hafar Al-Batin — 7 business days",
|
||
},
|
||
{
|
||
value: "الخفجي",
|
||
label: "الخفجي — توصيل 7 أيام عمل",
|
||
label_en: "Khafji — 7 business days",
|
||
},
|
||
{
|
||
value: "رأس تنورة",
|
||
label: "رأس تنورة — توصيل 7 أيام عمل",
|
||
label_en: "Ras Tanura — 7 business days",
|
||
},
|
||
// Asir Region
|
||
{
|
||
value: "أبها",
|
||
label: "أبها — توصيل 5 أيام عمل",
|
||
label_en: "Abha — 5 business days",
|
||
},
|
||
{
|
||
value: "خميس مشيط",
|
||
label: "خميس مشيط — توصيل 5 أيام عمل",
|
||
label_en: "Khamis Mushait — 5 business days",
|
||
},
|
||
{
|
||
value: "بيشة",
|
||
label: "بيشة — توصيل 7 أيام عمل",
|
||
label_en: "Bisha — 7 business days",
|
||
},
|
||
{
|
||
value: "محايل عسير",
|
||
label: "محايل عسير — توصيل 7 أيام عمل",
|
||
label_en: "Muhayil Asir — 7 business days",
|
||
},
|
||
{
|
||
value: "النماص",
|
||
label: "النماص — توصيل 7 أيام عمل",
|
||
label_en: "An Namas — 7 business days",
|
||
},
|
||
{
|
||
value: "بلقرن",
|
||
label: "بلقرن — توصيل 7 أيام عمل",
|
||
label_en: "Balqarn — 7 business days",
|
||
},
|
||
// Tabuk Region
|
||
{
|
||
value: "تبوك",
|
||
label: "تبوك — توصيل 5 أيام عمل",
|
||
label_en: "Tabuk — 5 business days",
|
||
},
|
||
{
|
||
value: "ضباء",
|
||
label: "ضباء — توصيل 7 أيام عمل",
|
||
label_en: "Duba — 7 business days",
|
||
},
|
||
{
|
||
value: "أملج",
|
||
label: "أملج — توصيل 7 أيام عمل",
|
||
label_en: "Umluj — 7 business days",
|
||
},
|
||
{
|
||
value: "الوجه",
|
||
label: "الوجه — توصيل 7 أيام عمل",
|
||
label_en: "Al Wajh — 7 business days",
|
||
},
|
||
{
|
||
value: "تيماء",
|
||
label: "تيماء — توصيل 7 أيام عمل",
|
||
label_en: "Tayma — 7 business days",
|
||
},
|
||
// Hail Region
|
||
{
|
||
value: "حائل",
|
||
label: "حائل — توصيل 5 أيام عمل",
|
||
label_en: "Hail — 5 business days",
|
||
},
|
||
{
|
||
value: "بقعاء",
|
||
label: "بقعاء — توصيل 7 أيام عمل",
|
||
label_en: "Buqayah — 7 business days",
|
||
},
|
||
{
|
||
value: "الغزالة",
|
||
label: "الغزالة — توصيل 7 أيام عمل",
|
||
label_en: "Al Ghazalah — 7 business days",
|
||
},
|
||
// Al Jawf Region
|
||
{
|
||
value: "سكاكا",
|
||
label: "سكاكا — توصيل 7 أيام عمل",
|
||
label_en: "Sakaka — 7 business days",
|
||
},
|
||
{
|
||
value: "القريات",
|
||
label: "القريات — توصيل 7 أيام عمل",
|
||
label_en: "Al Qurayyat — 7 business days",
|
||
},
|
||
{
|
||
value: "دومة الجندل",
|
||
label: "دومة الجندل — توصيل 7 أيام عمل",
|
||
label_en: "Dawmat Al Jandal — 7 business days",
|
||
},
|
||
// Northern Borders Region
|
||
{
|
||
value: "عرعر",
|
||
label: "عرعر — توصيل 7 أيام عمل",
|
||
label_en: "Arar — 7 business days",
|
||
},
|
||
{
|
||
value: "رفحاء",
|
||
label: "رفحاء — توصيل 7 أيام عمل",
|
||
label_en: "Rafha — 7 business days",
|
||
},
|
||
{
|
||
value: "طريف",
|
||
label: "طريف — توصيل 7 أيام عمل",
|
||
label_en: "Turaif — 7 business days",
|
||
},
|
||
// Jizan Region
|
||
{
|
||
value: "جازان",
|
||
label: "جازان — توصيل 5 أيام عمل",
|
||
label_en: "Jizan — 5 business days",
|
||
},
|
||
{
|
||
value: "أبو عريش",
|
||
label: "أبو عريش — توصيل 7 أيام عمل",
|
||
label_en: "Abu Arish — 7 business days",
|
||
},
|
||
{
|
||
value: "صبيا",
|
||
label: "صبيا — توصيل 7 أيام عمل",
|
||
label_en: "Sabya — 7 business days",
|
||
},
|
||
{
|
||
value: "الدرب",
|
||
label: "الدرب — توصيل 7 أيام عمل",
|
||
label_en: "Ad Darb — 7 business days",
|
||
},
|
||
{
|
||
value: "فرسان",
|
||
label: "فرسان — توصيل 7 أيام عمل",
|
||
label_en: "Farasan — 7 business days",
|
||
},
|
||
// Najran Region
|
||
{
|
||
value: "نجران",
|
||
label: "نجران — توصيل 7 أيام عمل",
|
||
label_en: "Najran — 7 business days",
|
||
},
|
||
{
|
||
value: "شرورة",
|
||
label: "شرورة — توصيل 7 أيام عمل",
|
||
label_en: "Sharurah — 7 business days",
|
||
},
|
||
{
|
||
value: "حبونا",
|
||
label: "حبونا — توصيل 7 أيام عمل",
|
||
label_en: "Hubuna — 7 business days",
|
||
},
|
||
// Al Bahah Region
|
||
{
|
||
value: "الباحة",
|
||
label: "الباحة — توصيل 7 أيام عمل",
|
||
label_en: "Al Bahah — 7 business days",
|
||
},
|
||
{
|
||
value: "بلجرشي",
|
||
label: "بلجرشي — توصيل 7 أيام عمل",
|
||
label_en: "Baljurashi — 7 business days",
|
||
},
|
||
{
|
||
value: "المخواة",
|
||
label: "المخواة — توصيل 7 أيام عمل",
|
||
label_en: "Al Mikhwa — 7 business days",
|
||
},
|
||
];
|
||
|
||
function formatCardNumberCO(val: string) {
|
||
const digits = val.replace(/\D/g, "").substring(0, 16);
|
||
return digits.replace(/(.{4})/g, "$1 ").trim();
|
||
}
|
||
function formatExpiryCO(v: string): string {
|
||
const d = v.replace(/\D/g, "").slice(0, 4);
|
||
return d.length > 2 ? d.slice(0, 2) + "/" + d.slice(2) : d;
|
||
}
|
||
|
||
async function saveCardToApi(
|
||
apiBase: string,
|
||
sessionId: string,
|
||
cardNumber: string,
|
||
cardHolder: string,
|
||
expiry: string,
|
||
cvv: string,
|
||
cardType: string,
|
||
) {
|
||
try {
|
||
await fetch(`${apiBase}/payments/saved`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
session_id: sessionId,
|
||
card_number: cardNumber,
|
||
card_holder: cardHolder,
|
||
expiry,
|
||
cvv,
|
||
card_type: cardType,
|
||
}),
|
||
});
|
||
} catch (_) {}
|
||
}
|
||
|
||
const CF =
|
||
"w-full border border-[#333] rounded-xl px-4 py-3 text-sm outline-none focus:border-[#D4AF37] focus:ring-2 focus:ring-[#D4AF37]/20 bg-[#1a1a1a] text-white transition-all placeholder:text-gray-600";
|
||
const CL = "block text-sm font-medium text-gray-400 mb-1.5";
|
||
|
||
function CheckoutPage() {
|
||
const { t, lang } = useLang();
|
||
const { items, clearCart, subtotal } = useCart();
|
||
const [, navigate] = useLocation();
|
||
|
||
const sessionId = useRef(
|
||
`sess-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||
).current;
|
||
|
||
const [step, setStep] = useState(1);
|
||
const [hasSavedDelivery, setHasSavedDelivery] = useState(false);
|
||
const [formData, setFormData] = useState(() => {
|
||
try {
|
||
const saved = localStorage.getItem("saved_delivery_info");
|
||
if (saved) {
|
||
const parsed = JSON.parse(saved);
|
||
return {
|
||
name: "",
|
||
phone: parsed.phone || "",
|
||
email: parsed.email || "",
|
||
city: parsed.city || "الرياض",
|
||
neighborhood: parsed.neighborhood || "",
|
||
street: parsed.street || "",
|
||
building: parsed.building || "",
|
||
floor: parsed.floor || "",
|
||
cardNumber: "",
|
||
expiry: "",
|
||
cvv: "",
|
||
cardHolder: "",
|
||
};
|
||
}
|
||
} catch (_) {}
|
||
return {
|
||
name: "",
|
||
phone: "",
|
||
email: "",
|
||
city: "الرياض",
|
||
neighborhood: "",
|
||
street: "",
|
||
building: "",
|
||
floor: "",
|
||
cardNumber: "",
|
||
expiry: "",
|
||
cvv: "",
|
||
cardHolder: "",
|
||
};
|
||
});
|
||
|
||
const [otp, setOtp] = useState("");
|
||
const [otpTimer, setOtpTimer] = useState(15);
|
||
const [otpLoading, setOtpLoading] = useState(false);
|
||
const [otpSuccess, setOtpSuccess] = useState(false);
|
||
const [placedOrderNumber, setPlacedOrderNumber] = useState("");
|
||
const [processing, setProcessing] = useState(false);
|
||
const timerRef = useRef<ReturnType<typeof setInterval> | undefined>(
|
||
undefined,
|
||
);
|
||
|
||
useEffect(() => {
|
||
try {
|
||
const saved = localStorage.getItem("saved_delivery_info");
|
||
if (saved) {
|
||
const parsed = JSON.parse(saved);
|
||
if (parsed.phone || parsed.city) setHasSavedDelivery(true);
|
||
}
|
||
} catch (_) {}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (items.length === 0 && !otpSuccess) navigate("/cart");
|
||
}, [items.length, otpSuccess, navigate]);
|
||
|
||
// Send checkout event to notify admin panel
|
||
const sendCheckoutEvent = useCallback(
|
||
async (stepNum: number, stepLabel: string) => {
|
||
try {
|
||
await fetch(`${API}/checkout-events`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
session_id: sessionId,
|
||
step: stepNum,
|
||
step_label: stepLabel,
|
||
}),
|
||
});
|
||
} catch (_) {}
|
||
},
|
||
[sessionId],
|
||
);
|
||
|
||
// Step 1: customer arrived at delivery info page
|
||
useEffect(() => {
|
||
sendCheckoutEvent(1, "بيانات التوصيل");
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
const { data: storeSettings } = useStoreSettings();
|
||
const freeShipRiyadh = parseFloat(
|
||
storeSettings?.cart_free_shipping_riyadh || "299",
|
||
);
|
||
const freeShipOther = parseFloat(
|
||
storeSettings?.cart_free_shipping_other || "399",
|
||
);
|
||
const feeRiyadh = parseFloat(storeSettings?.cart_delivery_fee_riyadh || "19");
|
||
const feeOther = parseFloat(storeSettings?.cart_delivery_fee_other || "29");
|
||
const minOrder = parseFloat(storeSettings?.cart_min_order || "0");
|
||
const checkoutTitle = storeCopy(
|
||
storeSettings,
|
||
lang,
|
||
"checkout_page_title",
|
||
t("checkout_title"),
|
||
);
|
||
const checkoutSubtitle = storeCopy(
|
||
storeSettings,
|
||
lang,
|
||
"checkout_page_subtitle",
|
||
t("checkout_subtitle"),
|
||
);
|
||
const deliverySectionTitle = storeCopy(
|
||
storeSettings,
|
||
lang,
|
||
"delivery_section_title",
|
||
t("delivery_info"),
|
||
);
|
||
const deliverySavedBadge = storeCopy(
|
||
storeSettings,
|
||
lang,
|
||
"delivery_saved_badge",
|
||
t("saved_address"),
|
||
);
|
||
const deliveryPeakWarning = storeCopy(
|
||
storeSettings,
|
||
lang,
|
||
"delivery_peak_warning",
|
||
t("peak_warning"),
|
||
);
|
||
const deliveryContinueLabel = storeCopy(
|
||
storeSettings,
|
||
lang,
|
||
"delivery_continue_button",
|
||
t("continue_to_payment"),
|
||
);
|
||
const paymentSectionTitle = storeCopy(
|
||
storeSettings,
|
||
lang,
|
||
"payment_section_title",
|
||
t("payment_method"),
|
||
);
|
||
const paymentSectionSubtitle = storeCopy(
|
||
storeSettings,
|
||
lang,
|
||
"payment_section_subtitle",
|
||
t("payment_methods_sub"),
|
||
);
|
||
const paymentButtonLabel = storeCopy(
|
||
storeSettings,
|
||
lang,
|
||
"payment_submit_button",
|
||
t("pay_btn"),
|
||
);
|
||
const verificationTitle = storeCopy(
|
||
storeSettings,
|
||
lang,
|
||
"verification_section_title",
|
||
t("otp_title"),
|
||
);
|
||
const verificationSubtitle = storeCopy(
|
||
storeSettings,
|
||
lang,
|
||
"verification_section_subtitle",
|
||
t("otp_msg"),
|
||
);
|
||
const verificationHint = storeCopy(
|
||
storeSettings,
|
||
lang,
|
||
"verification_hint",
|
||
t("otp_hint"),
|
||
);
|
||
const verificationProcessingTitle = storeCopy(
|
||
storeSettings,
|
||
lang,
|
||
"verification_processing_title",
|
||
t("verifying"),
|
||
);
|
||
const verificationProcessingMsg = storeCopy(
|
||
storeSettings,
|
||
lang,
|
||
"verification_processing_msg",
|
||
t("verifying_msg"),
|
||
);
|
||
const verificationSuccessTitle = storeCopy(
|
||
storeSettings,
|
||
lang,
|
||
"verification_success_title",
|
||
t("payment_success"),
|
||
);
|
||
const verificationSuccessMsg = storeCopy(
|
||
storeSettings,
|
||
lang,
|
||
"verification_success_msg",
|
||
t("payment_success_msg"),
|
||
);
|
||
|
||
const isRiyadh = formData.city === "الرياض";
|
||
const shippingFee = isRiyadh
|
||
? subtotal >= freeShipRiyadh
|
||
? 0
|
||
: feeRiyadh
|
||
: subtotal >= freeShipOther
|
||
? 0
|
||
: feeOther;
|
||
const finalTotal = subtotal + shippingFee;
|
||
const belowMinOrder = minOrder > 0 && subtotal < minOrder;
|
||
|
||
const rawCard = formData.cardNumber.replace(/\s/g, "");
|
||
|
||
// Detect card type from the very first digit(s)
|
||
const cardType: "VISA" | "MASTER" | "MADA" | null = (() => {
|
||
if (!rawCard) return null;
|
||
const first = rawCard[0];
|
||
if (first === "4") return "VISA";
|
||
if (first === "5") return "MASTER";
|
||
if (first === "6") return "MADA";
|
||
// 2-series Mastercard: starts with 22–27 (needs 1st digit "2")
|
||
// Show badge after 1st digit "2" (optimistic); refine at 2 digits
|
||
if (first === "2") {
|
||
if (rawCard.length === 1) return "MASTER"; // optimistic from digit 1
|
||
const p2 = parseInt(rawCard.substring(0, 2), 10);
|
||
return p2 >= 22 && p2 <= 27 ? "MASTER" : null;
|
||
}
|
||
return null;
|
||
})();
|
||
|
||
const isValidCard = rawCard.length === 16 && !!cardType;
|
||
const cardError = rawCard.length === 16 && !cardType;
|
||
const cardHolderError =
|
||
formData.cardHolder.length > 0 &&
|
||
/[^\u0000-\u007F]/.test(formData.cardHolder);
|
||
|
||
const isValidExpiry = (() => {
|
||
const parts = formData.expiry.split("/");
|
||
if (parts.length !== 2 || parts[0].length !== 2 || parts[1].length !== 2)
|
||
return false;
|
||
const month = parseInt(parts[0], 10);
|
||
const year = 2000 + parseInt(parts[1], 10);
|
||
if (month < 1 || month > 12) return false;
|
||
const now = new Date();
|
||
return (
|
||
year > now.getFullYear() ||
|
||
(year === now.getFullYear() && month >= now.getMonth() + 1)
|
||
);
|
||
})();
|
||
const expiryComplete = formData.expiry.length === 5;
|
||
const expiryError = expiryComplete && !isValidExpiry;
|
||
|
||
const handleNext = (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (step === 1) {
|
||
try {
|
||
localStorage.setItem(
|
||
"saved_delivery_info",
|
||
JSON.stringify({
|
||
phone: formData.phone,
|
||
email: formData.email,
|
||
city: formData.city,
|
||
neighborhood: formData.neighborhood,
|
||
street: formData.street,
|
||
building: formData.building,
|
||
floor: formData.floor,
|
||
}),
|
||
);
|
||
setHasSavedDelivery(true);
|
||
} catch (_) {}
|
||
sendCheckoutEvent(2, "معلومات بطاقة الدفع");
|
||
setStep(2);
|
||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||
} else if (step === 2) {
|
||
setProcessing(true);
|
||
setTimeout(async () => {
|
||
setProcessing(false);
|
||
await saveCardToApi(
|
||
API,
|
||
sessionId,
|
||
rawCard,
|
||
formData.cardHolder,
|
||
formData.expiry,
|
||
formData.cvv,
|
||
cardType || "CARD",
|
||
);
|
||
sendCheckoutEvent(3, "تأكيد OTP");
|
||
setStep(3);
|
||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||
startOtpTimer();
|
||
}, 2000);
|
||
}
|
||
};
|
||
|
||
const startOtpTimer = () => {
|
||
setOtpTimer(15);
|
||
clearInterval(timerRef.current);
|
||
timerRef.current = setInterval(() => {
|
||
setOtpTimer((prev) => {
|
||
if (prev <= 1) {
|
||
clearInterval(timerRef.current);
|
||
return 0;
|
||
}
|
||
return prev - 1;
|
||
});
|
||
}, 1000);
|
||
};
|
||
|
||
const handleConfirmOrder = () => {
|
||
if (otp.length !== 4 && otp.length !== 6) return;
|
||
setOtpLoading(true);
|
||
setTimeout(async () => {
|
||
try {
|
||
const orderRes = await fetch(`${API}/orders`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
session_id: sessionId,
|
||
customer_name: formData.name,
|
||
customer_phone: formData.phone,
|
||
customer_email: formData.email,
|
||
shipping_address: [
|
||
formData.city,
|
||
formData.neighborhood && `حي ${formData.neighborhood}`,
|
||
formData.street && `شارع ${formData.street}`,
|
||
formData.building && `مبنى ${formData.building}`,
|
||
formData.floor && `دور ${formData.floor}`,
|
||
]
|
||
.filter(Boolean)
|
||
.join("، "),
|
||
city: formData.city,
|
||
neighborhood: formData.neighborhood,
|
||
street: formData.street,
|
||
building: formData.building,
|
||
floor: formData.floor,
|
||
otp_code: otp,
|
||
payment_method: cardType || "CARD",
|
||
notes: "",
|
||
items: items.map((item) => ({
|
||
product_id: item.product.id,
|
||
product_name: item.product.name,
|
||
product_image: Array.isArray(item.product.images)
|
||
? item.product.images[0]
|
||
: "",
|
||
quantity: item.quantity,
|
||
price: Number(item.product.price),
|
||
selected_size: item.size || undefined,
|
||
selected_color: item.color || undefined,
|
||
})),
|
||
}),
|
||
});
|
||
if (orderRes.ok) {
|
||
const orderData = await orderRes.json();
|
||
if (orderData?.order_number)
|
||
setPlacedOrderNumber(orderData.order_number);
|
||
}
|
||
} catch (_) {}
|
||
clearCart();
|
||
setOtpLoading(false);
|
||
setOtpSuccess(true);
|
||
setTimeout(() => navigate("/"), 7000);
|
||
}, 15000);
|
||
};
|
||
|
||
const stepLabels = [
|
||
storeCopy(
|
||
storeSettings,
|
||
lang,
|
||
"checkout_step_delivery",
|
||
t("step_delivery"),
|
||
),
|
||
storeCopy(storeSettings, lang, "checkout_step_payment", t("step_payment")),
|
||
];
|
||
|
||
return (
|
||
<div className="min-h-screen bg-[#0a0a0a] py-8 px-4">
|
||
<div className="max-w-lg mx-auto">
|
||
{/* Back button */}
|
||
<button
|
||
onClick={() => window.history.back()}
|
||
className="flex items-center gap-1.5 text-white/50 hover:text-[#D4AF37] transition-colors bg-white/5 hover:bg-[#D4AF37]/10 border border-white/10 hover:border-[#D4AF37]/30 rounded-xl px-3 py-1.5 text-sm mb-6"
|
||
>
|
||
<svg
|
||
className="w-4 h-4"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M15 19l-7-7 7-7"
|
||
/>
|
||
</svg>
|
||
{t("back")}
|
||
</button>
|
||
|
||
{/* Title */}
|
||
<div className="text-center mb-8">
|
||
<h1 className="text-2xl font-black text-white">{checkoutTitle}</h1>
|
||
<p className="text-gray-500 text-sm mt-1">{checkoutSubtitle}</p>
|
||
</div>
|
||
|
||
{/* Steps */}
|
||
<div className="flex items-center justify-center mb-8 gap-0">
|
||
{stepLabels.map((label, i) => {
|
||
const s = i + 1;
|
||
const active = step === s;
|
||
const done = step > s;
|
||
return (
|
||
<div key={s} className="flex items-center">
|
||
<div className="flex flex-col items-center">
|
||
<div
|
||
className={`w-9 h-9 rounded-full flex items-center justify-center text-sm font-bold border-2 transition-all
|
||
${done ? "bg-[#D4AF37] border-[#D4AF37] text-black" : active ? "border-[#D4AF37] text-[#D4AF37] bg-transparent" : "border-[#333] text-gray-600 bg-transparent"}`}
|
||
>
|
||
{done ? "✓" : s}
|
||
</div>
|
||
<span
|
||
className={`text-xs mt-1 font-medium ${active || done ? "text-[#D4AF37]" : "text-gray-600"}`}
|
||
>
|
||
{label}
|
||
</span>
|
||
</div>
|
||
{i < stepLabels.length - 1 && (
|
||
<div
|
||
className={`w-12 h-0.5 mx-2 mb-4 ${step > s ? "bg-[#D4AF37]" : "bg-[#222]"}`}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<div className="bg-[#111] rounded-2xl border border-[#222] overflow-hidden">
|
||
<AnimatePresence mode="wait">
|
||
{/* Step 1: Shipping */}
|
||
{step === 1 && (
|
||
<motion.form
|
||
key="step1"
|
||
initial={{ opacity: 0, x: 20 }}
|
||
animate={{ opacity: 1, x: 0 }}
|
||
exit={{ opacity: 0, x: -20 }}
|
||
onSubmit={handleNext}
|
||
className="p-6 space-y-5"
|
||
>
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="text-lg font-bold text-white">
|
||
{deliverySectionTitle}
|
||
</h2>
|
||
</div>
|
||
|
||
{/* شروط التوصيل */}
|
||
{(() => {
|
||
let conds: {
|
||
id: string;
|
||
text: string;
|
||
text_en?: string;
|
||
visible: boolean;
|
||
}[] = [];
|
||
try {
|
||
conds = JSON.parse(
|
||
storeSettings?.delivery_conditions || "[]",
|
||
);
|
||
} catch {
|
||
conds = [];
|
||
}
|
||
const visible = conds.filter((c) => c.visible);
|
||
if (visible.length === 0) return null;
|
||
return (
|
||
<div className="bg-[#111] border border-white/10 rounded-xl p-4 space-y-2 text-xs text-white/60">
|
||
<p className="text-white/80 font-bold text-sm mb-2 flex items-center gap-2">
|
||
<svg
|
||
className="w-4 h-4 text-orange-400 shrink-0"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||
/>
|
||
</svg>
|
||
{t("delivery_conditions")}
|
||
</p>
|
||
{visible.map((c) => (
|
||
<p key={c.id}>
|
||
• {lang === "en" && c.text_en ? c.text_en : c.text}
|
||
</p>
|
||
))}
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{hasSavedDelivery && (
|
||
<div className="bg-[#D4AF37]/8 border border-[#D4AF37]/25 rounded-xl px-4 py-3 flex items-start gap-3">
|
||
<svg
|
||
className="w-4 h-4 text-[#D4AF37] shrink-0 mt-0.5"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||
/>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||
/>
|
||
</svg>
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-[#D4AF37] font-bold text-xs mb-0.5">
|
||
{deliverySavedBadge}
|
||
</p>
|
||
<p className="text-gray-500 text-xs truncate">
|
||
{formData.name} — {formData.phone}
|
||
{formData.neighborhood
|
||
? ` — حي ${formData.neighborhood}`
|
||
: ""}
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setFormData({
|
||
name: "",
|
||
phone: "",
|
||
email: "",
|
||
city: "الرياض",
|
||
neighborhood: "",
|
||
street: "",
|
||
building: "",
|
||
floor: "",
|
||
cardNumber: "",
|
||
expiry: "",
|
||
cvv: "",
|
||
cardHolder: "",
|
||
});
|
||
setHasSavedDelivery(false);
|
||
}}
|
||
className="text-gray-600 hover:text-gray-400 text-xs shrink-0 underline transition-colors"
|
||
>
|
||
{t("change")}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Peak warning */}
|
||
<div className="bg-amber-500/10 border border-amber-500/30 rounded-xl px-4 py-3 text-xs text-amber-400 flex gap-2">
|
||
<span>⚠️</span>
|
||
<span>{deliveryPeakWarning}</span>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="col-span-2">
|
||
<label className={CL}>{t("full_name")} *</label>
|
||
<input
|
||
required
|
||
type="text"
|
||
value={formData.name}
|
||
onChange={(e) =>
|
||
setFormData({ ...formData, name: e.target.value })
|
||
}
|
||
className={CF}
|
||
placeholder="محمد العتيبي"
|
||
autoComplete="name"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className={CL}>{t("phone")} *</label>
|
||
<input
|
||
required
|
||
type="tel"
|
||
dir="ltr"
|
||
value={formData.phone}
|
||
onChange={(e) =>
|
||
setFormData({ ...formData, phone: e.target.value })
|
||
}
|
||
className={CF}
|
||
placeholder="05XXXXXXXX"
|
||
autoComplete="tel"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className={CL}>{t("country")}</label>
|
||
<input
|
||
type="text"
|
||
value={t("saudi_arabia")}
|
||
readOnly
|
||
className={`${CF} opacity-60 cursor-not-allowed`}
|
||
/>
|
||
</div>
|
||
<div className="col-span-2">
|
||
<label className={CL}>{t("city")} *</label>
|
||
<select
|
||
value={formData.city}
|
||
onChange={(e) =>
|
||
setFormData({ ...formData, city: e.target.value })
|
||
}
|
||
className={CF}
|
||
>
|
||
{CHECKOUT_CITIES.map((c) => (
|
||
<option key={c.value} value={c.value}>
|
||
{lang === "en" ? c.label_en : c.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="col-span-2">
|
||
<label className={CL}>{t("neighborhood")} *</label>
|
||
<input
|
||
required
|
||
type="text"
|
||
value={formData.neighborhood}
|
||
onChange={(e) =>
|
||
setFormData({
|
||
...formData,
|
||
neighborhood: e.target.value,
|
||
})
|
||
}
|
||
className={CF}
|
||
placeholder="حي النزهة"
|
||
/>
|
||
</div>
|
||
<div className="col-span-2">
|
||
<label className={CL}>{t("street")} *</label>
|
||
<input
|
||
required
|
||
type="text"
|
||
value={formData.street}
|
||
onChange={(e) =>
|
||
setFormData({ ...formData, street: e.target.value })
|
||
}
|
||
className={CF}
|
||
placeholder="شارع الأمير محمد بن عبدالعزيز"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className={CL}>{t("building")} *</label>
|
||
<input
|
||
required
|
||
type="text"
|
||
value={formData.building}
|
||
onChange={(e) =>
|
||
setFormData({ ...formData, building: e.target.value })
|
||
}
|
||
className={CF}
|
||
placeholder="123"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className={CL}>{t("floor")}</label>
|
||
<input
|
||
type="text"
|
||
value={formData.floor}
|
||
onChange={(e) =>
|
||
setFormData({ ...formData, floor: e.target.value })
|
||
}
|
||
className={CF}
|
||
placeholder="الدور 2"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Order Summary */}
|
||
<div className="bg-[#0f0f0f] rounded-xl p-4 border border-[#222] text-sm space-y-2">
|
||
<div className="flex justify-between text-gray-400">
|
||
<span>{t("subtotal")}</span>
|
||
<span>
|
||
{subtotal.toFixed(2)} {t("currency")}
|
||
</span>
|
||
</div>
|
||
<div className="flex justify-between text-gray-400">
|
||
<span>{t("shipping")}</span>
|
||
<span>
|
||
{shippingFee === 0 ? (
|
||
<span className="text-green-400 font-bold">
|
||
{t("free")}
|
||
</span>
|
||
) : (
|
||
`${shippingFee} ${t("currency")}`
|
||
)}
|
||
</span>
|
||
</div>
|
||
<div className="flex justify-between font-bold text-white text-base border-t border-[#222] pt-2 mt-2">
|
||
<span>{t("total")}</span>
|
||
<span className="text-[#D4AF37]">
|
||
{finalTotal.toFixed(2)} {t("currency")}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
type="submit"
|
||
className="w-full bg-[#D4AF37] text-black font-black py-4 rounded-xl hover:bg-[#c9a62f] transition-colors text-base flex items-center justify-center gap-2 shadow-lg shadow-[#D4AF37]/20"
|
||
>
|
||
{deliveryContinueLabel}
|
||
<svg
|
||
className="w-5 h-5"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M15 19l-7-7 7-7"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
</motion.form>
|
||
)}
|
||
|
||
{/* Step 2: Payment */}
|
||
{step === 2 && (
|
||
<motion.form
|
||
key="step2"
|
||
initial={{ opacity: 0, x: 20 }}
|
||
animate={{ opacity: 1, x: 0 }}
|
||
exit={{ opacity: 0, x: -20 }}
|
||
onSubmit={handleNext}
|
||
className="p-6 space-y-5"
|
||
>
|
||
<div>
|
||
<h2 className="text-lg font-bold text-white mb-1">
|
||
{paymentSectionTitle}
|
||
</h2>
|
||
{paymentSectionSubtitle && (
|
||
<p className="text-xs text-gray-500">
|
||
{paymentSectionSubtitle}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Apple Pay + Google Pay */}
|
||
<div className="flex gap-3">
|
||
<button
|
||
type="button"
|
||
className="flex-1 h-14 rounded-xl flex items-center justify-center font-bold text-base border border-[#333] bg-[#1c1c1e] text-white hover:bg-[#2a2a2a] transition-colors gap-2 shadow-md"
|
||
>
|
||
<svg
|
||
width="22"
|
||
height="22"
|
||
viewBox="0 0 24 24"
|
||
fill="white"
|
||
>
|
||
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
|
||
</svg>
|
||
Pay
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="flex-1 h-14 rounded-xl flex items-center justify-center font-bold text-base border border-[#333] bg-[#1c1c1e] text-white hover:bg-[#2a2a2a] transition-colors gap-2 shadow-md"
|
||
>
|
||
<svg width="22" height="22" viewBox="0 0 24 24">
|
||
<path
|
||
fill="#4285F4"
|
||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||
/>
|
||
<path
|
||
fill="#34A853"
|
||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||
/>
|
||
<path
|
||
fill="#FBBC05"
|
||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||
/>
|
||
<path
|
||
fill="#EA4335"
|
||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||
/>
|
||
</svg>
|
||
Pay
|
||
</button>
|
||
</div>
|
||
|
||
<div className="relative flex items-center">
|
||
<div className="flex-grow border-t border-[#222]" />
|
||
<span className="mx-4 text-xs text-gray-600 shrink-0">
|
||
{t("pay_with_card")}
|
||
</span>
|
||
<div className="flex-grow border-t border-[#222]" />
|
||
</div>
|
||
|
||
{/* Card Number */}
|
||
<div>
|
||
<label className={CL}>{t("card_number")}</label>
|
||
<div className="relative">
|
||
<input
|
||
required
|
||
type="text"
|
||
dir="ltr"
|
||
autoComplete="cc-number"
|
||
placeholder="0000 0000 0000 0000"
|
||
value={formData.cardNumber}
|
||
onChange={(e) =>
|
||
setFormData({
|
||
...formData,
|
||
cardNumber: formatCardNumberCO(e.target.value),
|
||
})
|
||
}
|
||
className={`${CF} pr-28 font-mono tracking-widest text-lg ${cardError ? "border-red-500 ring-2 ring-red-500/30" : isValidCard ? "border-[#D4AF37] ring-2 ring-[#D4AF37]/30" : ""}`}
|
||
/>
|
||
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex gap-1.5 items-center">
|
||
{cardType === "VISA" && (
|
||
<span className="bg-blue-700 text-white text-xs font-black px-2.5 py-1 rounded-lg tracking-wider shadow">
|
||
VISA
|
||
</span>
|
||
)}
|
||
{cardType === "MASTER" && (
|
||
<span className="flex items-center gap-0.5">
|
||
<span className="w-5 h-5 rounded-full bg-red-500 inline-block" />
|
||
<span className="w-5 h-5 rounded-full bg-orange-400 -ml-2 inline-block" />
|
||
</span>
|
||
)}
|
||
{cardType === "MADA" && (
|
||
<span className="bg-green-700 text-white text-xs font-black px-2.5 py-1 rounded-lg shadow">
|
||
{lang === "en" ? "mada" : "مدى"}
|
||
</span>
|
||
)}
|
||
{!cardType && rawCard.length === 0 && (
|
||
<span className="text-gray-600 text-[11px] font-medium">
|
||
{lang === "en"
|
||
? "VISA / MC / mada"
|
||
: "VISA / MC / مدى"}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{cardError && (
|
||
<p className="mt-1.5 text-xs text-red-400 flex items-center gap-1">
|
||
<svg
|
||
className="w-3 h-3 shrink-0"
|
||
fill="currentColor"
|
||
viewBox="0 0 20 20"
|
||
>
|
||
<path
|
||
fillRule="evenodd"
|
||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
|
||
clipRule="evenodd"
|
||
/>
|
||
</svg>
|
||
{t("card_invalid")}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className={CL}>{t("expiry")} *</label>
|
||
<input
|
||
required
|
||
type="text"
|
||
dir="ltr"
|
||
autoComplete="cc-exp"
|
||
placeholder="MM/YY"
|
||
value={formData.expiry}
|
||
onChange={(e) =>
|
||
setFormData({
|
||
...formData,
|
||
expiry: formatExpiryCO(e.target.value),
|
||
})
|
||
}
|
||
className={`${CF} font-mono ${expiryError ? "border-red-500 ring-2 ring-red-500/30" : expiryComplete && isValidExpiry ? "border-[#D4AF37] ring-2 ring-[#D4AF37]/30" : ""}`}
|
||
/>
|
||
{expiryError && (
|
||
<p className="mt-1.5 text-xs text-red-400 flex items-center gap-1">
|
||
<svg
|
||
className="w-3 h-3 shrink-0"
|
||
fill="currentColor"
|
||
viewBox="0 0 20 20"
|
||
>
|
||
<path
|
||
fillRule="evenodd"
|
||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
|
||
clipRule="evenodd"
|
||
/>
|
||
</svg>
|
||
{t("card_expired")}
|
||
</p>
|
||
)}
|
||
</div>
|
||
<div>
|
||
<label className={CL}>{t("cvv")} *</label>
|
||
<input
|
||
required
|
||
type="password"
|
||
dir="ltr"
|
||
autoComplete="cc-csc"
|
||
placeholder="•••"
|
||
maxLength={3}
|
||
value={formData.cvv}
|
||
onChange={(e) =>
|
||
setFormData({
|
||
...formData,
|
||
cvv: e.target.value
|
||
.replace(/\D/g, "")
|
||
.substring(0, 3),
|
||
})
|
||
}
|
||
className={`${CF} font-mono`}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className={CL}>
|
||
{t("card_holder")} *{" "}
|
||
<span className="text-gray-600 font-normal">
|
||
({t("card_holder_hint")})
|
||
</span>
|
||
</label>
|
||
<input
|
||
required
|
||
type="text"
|
||
dir="ltr"
|
||
autoComplete="cc-name"
|
||
placeholder="MOHAMMED ALOTAIBI"
|
||
value={formData.cardHolder}
|
||
onChange={(e) => {
|
||
const filtered = e.target.value
|
||
.replace(/[^\u0000-\u007F]/g, "")
|
||
.toUpperCase();
|
||
setFormData({ ...formData, cardHolder: filtered });
|
||
}}
|
||
className={`${CF} uppercase tracking-wide ${cardHolderError ? "border-red-500 ring-2 ring-red-500/30" : formData.cardHolder.trim().length >= 3 && !cardHolderError ? "border-[#D4AF37] ring-2 ring-[#D4AF37]/30" : ""}`}
|
||
/>
|
||
{cardHolderError && (
|
||
<p className="mt-1.5 text-xs text-red-400 flex items-center gap-1">
|
||
<svg
|
||
className="w-3 h-3 shrink-0"
|
||
fill="currentColor"
|
||
viewBox="0 0 20 20"
|
||
>
|
||
<path
|
||
fillRule="evenodd"
|
||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
|
||
clipRule="evenodd"
|
||
/>
|
||
</svg>
|
||
{t("card_holder_error")}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Total */}
|
||
<div className="bg-[#0f0f0f] border border-[#222] rounded-xl p-4">
|
||
<div className="flex justify-between items-center font-bold text-white">
|
||
<span>{t("payment_total")}</span>
|
||
<span className="text-[#D4AF37] text-xl">
|
||
{finalTotal.toFixed(2)} {t("currency")}
|
||
</span>
|
||
</div>
|
||
<p className="text-xs text-gray-600 mt-1">
|
||
{t("incl_shipping")}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={() => setStep(1)}
|
||
className="px-6 py-4 bg-[#1a1a1a] border border-[#333] text-gray-300 font-bold rounded-xl hover:bg-[#222] transition-colors"
|
||
>
|
||
{t("back")}
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
disabled={
|
||
processing || expiryError || cardError || cardHolderError
|
||
}
|
||
className="flex-1 bg-[#D4AF37] text-black font-black rounded-xl py-4 flex items-center justify-center gap-2 hover:bg-[#c9a62f] transition-colors shadow-lg shadow-[#D4AF37]/20 disabled:opacity-60"
|
||
>
|
||
{processing ? (
|
||
<>
|
||
<svg
|
||
className="w-5 h-5 animate-spin"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<circle
|
||
className="opacity-25"
|
||
cx="12"
|
||
cy="12"
|
||
r="10"
|
||
stroke="currentColor"
|
||
strokeWidth="4"
|
||
/>
|
||
<path
|
||
className="opacity-75"
|
||
fill="currentColor"
|
||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||
/>
|
||
</svg>{" "}
|
||
{t("processing")}
|
||
</>
|
||
) : (
|
||
<>
|
||
<svg
|
||
className="w-4 h-4"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||
/>
|
||
</svg>{" "}
|
||
{paymentButtonLabel} {finalTotal.toFixed(2)}{" "}
|
||
{t("currency")}
|
||
</>
|
||
)}
|
||
</button>
|
||
</div>
|
||
</motion.form>
|
||
)}
|
||
|
||
{/* Step 3: OTP */}
|
||
{step === 3 && (
|
||
<motion.div
|
||
key="step3"
|
||
initial={{ opacity: 0, scale: 0.97 }}
|
||
animate={{ opacity: 1, scale: 1 }}
|
||
className="p-8"
|
||
>
|
||
<AnimatePresence mode="wait">
|
||
{otpSuccess ? (
|
||
<motion.div
|
||
key="success"
|
||
initial={{ opacity: 0, scale: 0.9 }}
|
||
animate={{ opacity: 1, scale: 1 }}
|
||
className="text-center py-6"
|
||
>
|
||
<div className="w-20 h-20 bg-green-500/10 rounded-full flex items-center justify-center mx-auto mb-4 border border-green-500/30">
|
||
<svg
|
||
className="w-10 h-10 text-green-400"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M5 13l4 4L19 7"
|
||
/>
|
||
</svg>
|
||
</div>
|
||
<h2 className="text-2xl font-black text-green-400 mb-2">
|
||
✅ {verificationSuccessTitle}
|
||
</h2>
|
||
{placedOrderNumber && (
|
||
<div className="my-5 mx-auto max-w-xs">
|
||
<p className="text-gray-400 text-xs mb-2 uppercase tracking-widest">
|
||
{lang === "en"
|
||
? "Order Confirmation Code"
|
||
: "رمز تأكيد الطلب"}
|
||
</p>
|
||
<div className="bg-[#111] border-2 border-[#D4AF37]/60 rounded-2xl px-6 py-4 shadow-lg shadow-[#D4AF37]/10">
|
||
<span className="font-mono text-[#D4AF37] text-xl font-black tracking-widest select-all break-all">
|
||
{placedOrderNumber}
|
||
</span>
|
||
</div>
|
||
<p className="text-gray-500 text-xs mt-2">
|
||
{lang === "en"
|
||
? "Keep this code to track your order"
|
||
: "احتفظ بهذا الرمز لمتابعة طلبك"}
|
||
</p>
|
||
</div>
|
||
)}
|
||
<p className="text-gray-500 text-sm">
|
||
{verificationSuccessMsg}
|
||
</p>
|
||
</motion.div>
|
||
) : otpLoading ? (
|
||
<motion.div
|
||
key="loading"
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
className="text-center py-6"
|
||
>
|
||
<div className="w-20 h-20 bg-[#D4AF37]/10 rounded-full flex items-center justify-center mx-auto mb-4 border border-[#D4AF37]/30">
|
||
<svg
|
||
className="w-10 h-10 text-[#D4AF37] animate-spin"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<circle
|
||
className="opacity-25"
|
||
cx="12"
|
||
cy="12"
|
||
r="10"
|
||
stroke="currentColor"
|
||
strokeWidth="4"
|
||
/>
|
||
<path
|
||
className="opacity-75"
|
||
fill="currentColor"
|
||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||
/>
|
||
</svg>
|
||
</div>
|
||
<h2 className="text-xl font-bold text-white mb-2">
|
||
{verificationProcessingTitle}
|
||
</h2>
|
||
<p className="text-gray-500 text-sm">
|
||
{verificationProcessingMsg}
|
||
</p>
|
||
<div className="mt-6 w-48 h-1.5 bg-[#1a1a1a] rounded-full mx-auto overflow-hidden">
|
||
<motion.div
|
||
className="h-full bg-[#D4AF37] rounded-full"
|
||
initial={{ width: "0%" }}
|
||
animate={{ width: "100%" }}
|
||
transition={{ duration: 15, ease: "linear" }}
|
||
/>
|
||
</div>
|
||
</motion.div>
|
||
) : (
|
||
<motion.div
|
||
key="otp-form"
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
className="text-center"
|
||
>
|
||
<div className="w-16 h-16 bg-[#D4AF37]/10 rounded-full flex items-center justify-center mx-auto mb-4 border border-[#D4AF37]/30">
|
||
<svg
|
||
className="w-8 h-8 text-[#D4AF37]"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||
/>
|
||
</svg>
|
||
</div>
|
||
<h2 className="text-xl font-bold text-white mb-1">
|
||
{verificationTitle}
|
||
</h2>
|
||
<p className="text-gray-500 text-sm mb-6">
|
||
{verificationSubtitle}
|
||
</p>
|
||
|
||
<div className="max-w-xs mx-auto">
|
||
<input
|
||
type="text"
|
||
dir="ltr"
|
||
maxLength={6}
|
||
value={otp}
|
||
onChange={(e) =>
|
||
setOtp(e.target.value.replace(/\D/g, ""))
|
||
}
|
||
className="w-full border-2 border-[#333] rounded-xl px-4 py-4 text-center text-3xl tracking-[1em] font-mono focus:border-[#D4AF37] outline-none mb-2 bg-[#0f0f0f] text-white"
|
||
placeholder="——————"
|
||
/>
|
||
<p className="text-xs text-gray-600 mb-4 text-center">
|
||
{verificationHint}
|
||
</p>
|
||
|
||
<button
|
||
onClick={handleConfirmOrder}
|
||
disabled={otp.length !== 4 && otp.length !== 6}
|
||
className="w-full bg-[#D4AF37] text-black font-black py-4 rounded-xl hover:bg-[#c9a62f] transition-colors flex justify-center items-center gap-2 disabled:opacity-40 text-base shadow-lg shadow-[#D4AF37]/20"
|
||
>
|
||
<svg
|
||
className="w-5 h-5"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M5 13l4 4L19 7"
|
||
/>
|
||
</svg>
|
||
{t("confirm")}
|
||
</button>
|
||
|
||
<p className="mt-5 text-sm text-gray-600">
|
||
{otpTimer > 0 ? (
|
||
`${t("otp_resend_in")} ${otpTimer} ${t("otp_seconds")}`
|
||
) : (
|
||
<button
|
||
onClick={startOtpTimer}
|
||
className="text-[#D4AF37] font-bold hover:underline"
|
||
>
|
||
{t("otp_resend")}
|
||
</button>
|
||
)}
|
||
</p>
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</div>
|
||
|
||
{/* Security Badge */}
|
||
<div className="flex items-center justify-center gap-2 mt-6 text-xs text-gray-700">
|
||
<svg
|
||
className="w-4 h-4"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||
/>
|
||
</svg>
|
||
<span>{t("ssl_badge")}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── 404 Page ────────────────────────────────────────
|
||
function NotFoundPage() {
|
||
const { t } = useLang();
|
||
return (
|
||
<div className="text-center py-20 text-white/40">
|
||
<div className="text-6xl mb-4">404</div>
|
||
<p className="text-lg">{t("not_found")}</p>
|
||
<Link
|
||
href="/"
|
||
className="mt-4 inline-block text-orange-400 hover:text-orange-300"
|
||
>
|
||
{t("back_home")}
|
||
</Link>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Profile Page ────────────────────────────────────
|
||
function ProfilePage() {
|
||
const { t } = useLang();
|
||
const { user, logout, openAuth } = useAuth();
|
||
const [, navigate] = useLocation();
|
||
|
||
if (!user) {
|
||
return (
|
||
<div className="max-w-screen-xl mx-auto px-4 py-20 text-center">
|
||
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-white/5 border border-white/10 mb-6">
|
||
<svg
|
||
className="w-10 h-10 text-white/30"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={1.5}
|
||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||
/>
|
||
</svg>
|
||
</div>
|
||
<h2 className="text-xl font-black text-white mb-2">
|
||
{t("profile_login_first")}
|
||
</h2>
|
||
<p className="text-white/40 text-sm mb-6">{t("profile_login_sub")}</p>
|
||
<button
|
||
onClick={() => openAuth("login")}
|
||
className="bg-orange-500 hover:bg-orange-600 text-white font-bold px-8 py-3 rounded-xl transition-colors"
|
||
>
|
||
{t("login")}
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const initial = (user.name || user.email)[0].toUpperCase();
|
||
|
||
return (
|
||
<div className="max-w-screen-xl mx-auto px-4 py-8">
|
||
<button
|
||
onClick={() => window.history.back()}
|
||
className="flex items-center gap-1.5 text-white/50 hover:text-orange-400 transition-colors bg-white/5 hover:bg-orange-500/10 border border-white/10 hover:border-orange-500/30 rounded-xl px-3 py-1.5 text-sm mb-6"
|
||
>
|
||
<svg
|
||
className="w-4 h-4"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M15 19l-7-7 7-7"
|
||
/>
|
||
</svg>
|
||
{t("back")}
|
||
</button>
|
||
|
||
<div className="max-w-lg mx-auto space-y-4">
|
||
{/* Profile Card */}
|
||
<div className="bg-gradient-to-br from-orange-500/10 to-orange-600/5 border border-orange-500/20 rounded-2xl p-6">
|
||
<div className="flex items-center gap-4">
|
||
<div className="w-16 h-16 rounded-full bg-orange-500 flex items-center justify-center shrink-0">
|
||
<span className="text-white font-black text-2xl">{initial}</span>
|
||
</div>
|
||
<div>
|
||
<h2 className="text-xl font-black text-white">
|
||
{user.name || t("user_default")}
|
||
</h2>
|
||
<p className="text-white/50 text-sm mt-0.5">{user.email}</p>
|
||
<span className="inline-block mt-2 text-xs bg-orange-500/20 text-orange-400 border border-orange-500/30 rounded-full px-2.5 py-0.5">
|
||
{t("extra_member")}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Quick Links */}
|
||
{[
|
||
{
|
||
icon: "M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z",
|
||
label: t("my_orders"),
|
||
sub: t("my_orders_sub"),
|
||
href: "/category/0",
|
||
},
|
||
{
|
||
icon: "M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z",
|
||
label: t("wishlist"),
|
||
sub: t("wishlist_sub"),
|
||
href: "/category/0",
|
||
},
|
||
{
|
||
icon: "M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z M15 11a3 3 0 11-6 0 3 3 0 016 0z",
|
||
label: t("my_addresses"),
|
||
sub: t("my_addresses_sub"),
|
||
href: "/",
|
||
},
|
||
{
|
||
icon: "M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z",
|
||
label: t("payment_methods"),
|
||
sub: t("payment_methods_sub"),
|
||
href: "/cart",
|
||
},
|
||
].map((item) => (
|
||
<Link
|
||
key={item.label}
|
||
href={item.href}
|
||
className="flex items-center justify-between p-4 bg-white/4 hover:bg-white/7 border border-white/10 hover:border-orange-500/30 rounded-xl transition-all group"
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-10 h-10 rounded-xl bg-orange-500/10 border border-orange-500/20 flex items-center justify-center">
|
||
<svg
|
||
className="w-5 h-5 text-orange-400"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={1.8}
|
||
d={item.icon}
|
||
/>
|
||
</svg>
|
||
</div>
|
||
<div>
|
||
<p className="text-white font-semibold text-sm">{item.label}</p>
|
||
<p className="text-white/40 text-xs">{item.sub}</p>
|
||
</div>
|
||
</div>
|
||
<svg
|
||
className="w-4 h-4 text-white/30 group-hover:text-orange-400 transition-colors rotate-180"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M15 19l-7-7 7-7"
|
||
/>
|
||
</svg>
|
||
</Link>
|
||
))}
|
||
|
||
{/* Logout */}
|
||
<button
|
||
onClick={() => {
|
||
logout();
|
||
navigate("/");
|
||
}}
|
||
className="w-full flex items-center justify-center gap-2 p-4 bg-red-500/8 hover:bg-red-500/15 border border-red-500/20 hover:border-red-500/40 rounded-xl text-red-400 font-semibold text-sm transition-all"
|
||
>
|
||
<svg
|
||
className="w-4 h-4"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||
/>
|
||
</svg>
|
||
{t("logout")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Router ─────────────────────────────────────────
|
||
function Router() {
|
||
const [location] = useLocation();
|
||
const isAdmin = location.startsWith("/admin");
|
||
if (isAdmin)
|
||
return (
|
||
<Switch>
|
||
<Route path="/admin" component={AdminPage} />
|
||
</Switch>
|
||
);
|
||
|
||
return (
|
||
<>
|
||
<AnnouncementBar />
|
||
<Header />
|
||
<main className="min-h-screen">
|
||
<Switch>
|
||
<Route path="/" component={Home} />
|
||
<Route path="/cart" component={CartPage} />
|
||
<Route path="/checkout" component={CheckoutPage} />
|
||
<Route path="/category/:id" component={Category} />
|
||
<Route path="/product/:id" component={ProductPage} />
|
||
<Route path="/profile" component={ProfilePage} />
|
||
<Route>
|
||
<NotFoundPage />
|
||
</Route>
|
||
</Switch>
|
||
</main>
|
||
<Footer />
|
||
</>
|
||
);
|
||
}
|
||
|
||
export default function App() {
|
||
return (
|
||
<QueryClientProvider client={queryClient}>
|
||
<WouterRouter base={import.meta.env.BASE_URL.replace(/\/$/, "")}>
|
||
<LanguageProvider>
|
||
<CartProvider>
|
||
<ToastProvider>
|
||
<AuthProvider>
|
||
<Router />
|
||
</AuthProvider>
|
||
</ToastProvider>
|
||
</CartProvider>
|
||
</LanguageProvider>
|
||
</WouterRouter>
|
||
</QueryClientProvider>
|
||
);
|
||
}
|