Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8394bc404e | |||
| a0930f5996 | |||
| c076a5848a | |||
| d19131c2f7 | |||
| 1b955b1bfb | |||
| c672c5a63e | |||
| d24529c85e | |||
| b6fc31affc | |||
| e6bb0fc375 | |||
| 019e0c3ee8 | |||
| 6e5a52cdd8 | |||
| 34951a96b7 | |||
| c134f15862 | |||
| e7c8d501b5 | |||
| 4e6e08fabe | |||
| b50e40f1c2 | |||
| a67212a4e1 | |||
| a18357792f | |||
| 57baf85e34 | |||
| abd419a9c5 | |||
| 23dbfc9bfd | |||
| 46b2da142f | |||
| c9edddb904 | |||
| 8ff41dbb6c | |||
| c1f33d717e |
@@ -34,27 +34,27 @@ export default function LandingPage() {
|
||||
<NavbarLayoutFloatingInline
|
||||
navItems={[
|
||||
{
|
||||
name: "الرئيسية", id: "hero"},
|
||||
name: "Home", id: "hero"},
|
||||
{
|
||||
name: "حولنا", id: "about"},
|
||||
name: "About Us", id: "about"},
|
||||
{
|
||||
name: "الميزات", id: "features"},
|
||||
name: "Features", id: "features"},
|
||||
{
|
||||
name: "الخدمات", id: "products"},
|
||||
name: "Services", id: "products"},
|
||||
{
|
||||
name: "الأسعار", id: "pricing"},
|
||||
name: "Pricing", id: "pricing"},
|
||||
{
|
||||
name: "آراء العملاء", id: "testimonials"},
|
||||
name: "Testimonials", id: "testimonials"},
|
||||
{
|
||||
name: "الأسئلة الشائعة", id: "faq"},
|
||||
name: "FAQ", id: "faq"},
|
||||
{
|
||||
name: "اتصل بنا", id: "contact"},
|
||||
name: "Contact Us", id: "contact"},
|
||||
]}
|
||||
logoSrc="https://webuild-dev.s3.eu-north-1.amazonaws.com/default/no-image.jpg?id=u8l15k"
|
||||
logoAlt="Inwi Moneye Logo"
|
||||
brandName="Inwi Moneye"
|
||||
button={{
|
||||
text: "حمّل التطبيق", href: "#"}}
|
||||
text: "Download App", href: "#"}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -62,26 +62,26 @@ export default function LandingPage() {
|
||||
<HeroBillboardScroll
|
||||
background={{
|
||||
variant: "radial-gradient"}}
|
||||
title="Inwi Moneye: محفظتك الرقمية الشاملة"
|
||||
description="أرسل الأموال، ادفع الفواتير، وتحكّم في أموالك بذكاء وأمان، داخل المغرب وخارجه. مع Inwi Moneye، أنت تتحكم في مستقبلك المالي."
|
||||
title="Inwi Moneye: Your Comprehensive Digital Wallet"
|
||||
description="Send money, pay bills, and manage your finances smartly and securely, both in Morocco and abroad. With Inwi Moneye, you control your financial future."
|
||||
buttons={[
|
||||
{
|
||||
text: "حمّل التطبيق الآن", href: "#download"},
|
||||
text: "Download App Now", href: "#download"},
|
||||
{
|
||||
text: "اكتشف المزيد", href: "#about"},
|
||||
text: "Discover More", href: "#about"},
|
||||
]}
|
||||
imageSrc="http://img.b2bpic.net/free-photo/close-up-guy-with-ring-smartphone_23-2148450773.jpg"
|
||||
imageAlt="تطبيق Inwi Moneye على الهاتف الذكي"
|
||||
imageAlt="Inwi Moneye app on smartphone"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="about" data-section="about">
|
||||
<MediaAbout
|
||||
useInvertedBackground={false}
|
||||
title="عن Inwi Moneye: شريكك المالي الذكي"
|
||||
description="Inwi Moneye هو تطبيق المحفظة الإلكترونية المتطور من Inwi، المصمم لتبسيط معاملاتك المالية اليومية. يوفر لك حرية إرسال واستقبال الأموال، دفع فواتيرك، وشحن رصيدك بكل سهولة ويسر، مدعوماً بتقنيات الذكاء الاصطناعي لتقديم تجربة مصرفية ذكية ومخصصة تناسب احتياجاتك في المغرب وخارجه."
|
||||
title="About Inwi Moneye: Your Smart Financial Partner"
|
||||
description="Inwi Moneye is Inwi's advanced e-wallet application, designed to simplify your daily financial transactions. It offers you the freedom to send and receive money, pay your bills, and top up your balance with ease and convenience, powered by AI technologies to provide a smart, personalized banking experience tailored to your needs in Morocco and beyond."
|
||||
imageSrc="http://img.b2bpic.net/free-photo/advanced-technological-robot-interacting-with-money-finance_23-2151612658.jpg"
|
||||
imageAlt="أشخاص يتفاعلون مع تطبيق محفظة إلكترونية"
|
||||
imageAlt="People interacting with an e-wallet application"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -92,40 +92,40 @@ export default function LandingPage() {
|
||||
useInvertedBackground={true}
|
||||
features={[
|
||||
{
|
||||
id: "f1", label: "تحويلات سريعة وآمنة", title: "إرسال واستقبال الأموال", items: [
|
||||
"تحويلات فورية للأصدقاء والعائلة", "مدعومة محلياً ودولياً", "رسوم منخفضة وشفافة"],
|
||||
id: "f1", label: "Fast & Secure Transfers", title: "Send & Receive Money", items: [
|
||||
"Instant transfers to friends and family", "Supported locally and internationally", "Low and transparent fees"],
|
||||
buttons: [
|
||||
{
|
||||
text: "تعلم المزيد", href: "#"},
|
||||
text: "Learn More", href: "#"},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "f2", label: "ادفع بضغطة زر", title: "دفع الفواتير وشحن الرصيد", items: [
|
||||
"دفع فواتير Wi-Fi والإنترنت", "شحن رصيد الهاتف بسهولة", "مدفوعات متنوعة للخدمات"],
|
||||
id: "f2", label: "Pay with a Tap", title: "Pay Bills & Top Up", items: [
|
||||
"Pay Wi-Fi and internet bills", "Easily top up phone balance", "Various service payments"],
|
||||
buttons: [
|
||||
{
|
||||
text: "اكتشف الفواتير", href: "#"},
|
||||
text: "Explore Bills", href: "#"},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "f3", label: "إدارة موحدة", title: "الربط بالحسابات البنكية", items: [
|
||||
"ربط محفظتك بحساباتك الشخصية", "تحويلات سلسة بين البنوك والمحفظة", "تحكم كامل في أموالك"],
|
||||
id: "f3", label: "Unified Management", title: "Link Bank Accounts", items: [
|
||||
"Link your wallet to your personal accounts", "Seamless transfers between banks and wallet", "Full control over your finances"],
|
||||
buttons: [
|
||||
{
|
||||
text: "ابدأ الربط", href: "#"},
|
||||
text: "Start Linking", href: "#"},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "f4", label: "مساعدك المالي", title: "دعم الذكاء الاصطناعي", items: [
|
||||
"رؤى واقتراحات مخصصة للإنفاق", "تحليل ذكي لمعاملاتك", "تخطيط مالي أفضل بمساعدة AI"],
|
||||
id: "f4", label: "Your Financial Assistant", title: "AI-Powered Support", items: [
|
||||
"Personalized spending insights and suggestions", "Smart analysis of your transactions", "Better financial planning with AI assistance"],
|
||||
buttons: [
|
||||
{
|
||||
text: "جرب AI", href: "#"},
|
||||
text: "Try AI", href: "#"},
|
||||
],
|
||||
},
|
||||
]}
|
||||
title="ميزات Inwi Moneye الرئيسية"
|
||||
description="استمتع بمجموعة واسعة من الخدمات المصممة لجعل إدارة أموالك أكثر كفاءة وأمانًا وذكاءً."
|
||||
title="Key Features of Inwi Moneye"
|
||||
description="Enjoy a wide range of services designed to make managing your money more efficient, secure, and smart."
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -227,7 +227,7 @@ export default function LandingPage() {
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/middle-aged-people-asking-advice-assistance-regarding-pension-plans_482257-106912.jpg", imageAlt: "خالد رشيدي"},
|
||||
]}
|
||||
showRating={true}
|
||||
title="ماذا يقول عملاؤنا؟"
|
||||
title="ماذا يقول عملاؤنا?"
|
||||
description="تجارب حقيقية من مستخدمي Inwi Moneye السعداء الذين يثقون بخدماتنا."
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -10,15 +10,15 @@
|
||||
--accent: #ffffff;
|
||||
--background-accent: #ffffff; */
|
||||
|
||||
--background: #000000;
|
||||
--card: #0c0c0c;
|
||||
--foreground: #ffffff;
|
||||
--primary-cta: #106EFB;
|
||||
--background: #ffffff;
|
||||
--card: #f9f9f9;
|
||||
--foreground: #000612e6;
|
||||
--primary-cta: #15479c;
|
||||
--primary-cta-text: #ffffff;
|
||||
--secondary-cta: #000000;
|
||||
--secondary-cta: #f9f9f9;
|
||||
--secondary-cta-text: #ffffff;
|
||||
--accent: #535353;
|
||||
--background-accent: #106EFB;
|
||||
--accent: #e2e2e2;
|
||||
--background-accent: #c4c4c4;
|
||||
|
||||
/* text sizing - set by ThemeProvider */
|
||||
/* --text-2xs: clamp(0.465rem, 0.62vw, 0.62rem);
|
||||
|
||||
@@ -1,118 +1,88 @@
|
||||
import { useEffect, useState, useRef, RefObject } from "react";
|
||||
import { useEffect } from 'react';
|
||||
import { useMotionValue, useSpring, useTransform } from 'framer-motion';
|
||||
import { Variants } from '@/types/AnimatePresence';
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
const ANIMATION_SPEED = 0.05;
|
||||
const ROTATION_SPEED = 0.1;
|
||||
const MOUSE_MULTIPLIER = 0.5;
|
||||
const ROTATION_MULTIPLIER = 0.25;
|
||||
|
||||
interface UseDepth3DAnimationProps {
|
||||
itemRefs: RefObject<(HTMLElement | null)[]>;
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
perspectiveRef?: RefObject<HTMLDivElement | null>;
|
||||
isEnabled: boolean;
|
||||
interface Depth3DAnimationProps {
|
||||
perspective?: number;
|
||||
depthFactor?: number;
|
||||
hoverScale?: number;
|
||||
transition?: {
|
||||
duration?: number;
|
||||
ease?: string;
|
||||
};
|
||||
springOptions?: {
|
||||
stiffness?: number;
|
||||
damping?: number;
|
||||
mass?: number;
|
||||
};
|
||||
rotationXRange?: number[];
|
||||
rotationYRange?: number[];
|
||||
}
|
||||
|
||||
export const useDepth3DAnimation = ({
|
||||
itemRefs,
|
||||
containerRef,
|
||||
perspectiveRef,
|
||||
isEnabled,
|
||||
}: UseDepth3DAnimationProps) => {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
perspective = 2000,
|
||||
depthFactor = 20,
|
||||
hoverScale = 1.05,
|
||||
transition = { duration: 0.8, ease: [0.6, 0.01, -0.05, 0.9] },
|
||||
springOptions = { stiffness: 400, damping: 10, mass: 1 },
|
||||
rotationXRange = [-10, 10],
|
||||
rotationYRange = [-10, 10],
|
||||
}: Depth3DAnimationProps) => {
|
||||
const x = useMotionValue(0);
|
||||
const y = useMotionValue(0);
|
||||
|
||||
const mouseXSpring = useSpring(x, springOptions);
|
||||
const mouseYSpring = useSpring(y, springOptions);
|
||||
|
||||
const rotateX = useTransform(
|
||||
mouseYSpring,
|
||||
[-0.5, 0.5],
|
||||
rotationXRange as Variants<number[]>,
|
||||
);
|
||||
const rotateY = useTransform(
|
||||
mouseXSpring,
|
||||
[-0.5, 0.5],
|
||||
rotationYRange as Variants<number[]>,
|
||||
);
|
||||
const scale = useTransform(mouseXSpring, [-0.5, 0.5], [1, hoverScale]);
|
||||
|
||||
const handleMouseMove = (event: React.MouseEvent) => {
|
||||
const rect = (event.target as HTMLElement).getBoundingClientRect();
|
||||
const width = rect.width;
|
||||
const height = rect.height;
|
||||
const mouseX = event.clientX - rect.left;
|
||||
const mouseY = event.clientY - rect.top;
|
||||
const xPct = mouseX / width - 0.5;
|
||||
const yPct = mouseY / height - 0.5;
|
||||
x.set(xPct);
|
||||
y.set(yPct);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
x.set(0);
|
||||
y.set(0);
|
||||
};
|
||||
|
||||
// Detect mobile viewport
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
};
|
||||
|
||||
checkMobile();
|
||||
window.addEventListener("resize", checkMobile);
|
||||
|
||||
// Cleanup function if needed, though Framer Motion handles many subscriptions internally.
|
||||
return () => {
|
||||
window.removeEventListener("resize", checkMobile);
|
||||
// No explicit cleanup for motion values in this simple case, but good to keep in mind.
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 3D mouse-tracking effect (desktop only)
|
||||
useEffect(() => {
|
||||
if (!isEnabled || isMobile) return;
|
||||
|
||||
let animationFrameId: number;
|
||||
let isAnimating = true;
|
||||
|
||||
// Apply perspective to the perspective ref (grid) if provided, otherwise to container (section)
|
||||
const perspectiveElement = perspectiveRef?.current || containerRef.current;
|
||||
if (perspectiveElement) {
|
||||
perspectiveElement.style.perspective = "1200px";
|
||||
perspectiveElement.style.transformStyle = "preserve-3d";
|
||||
}
|
||||
|
||||
let mouseX = 0;
|
||||
let mouseY = 0;
|
||||
let isMouseInSection = false;
|
||||
|
||||
let currentX = 0;
|
||||
let currentY = 0;
|
||||
let currentRotationX = 0;
|
||||
let currentRotationY = 0;
|
||||
|
||||
const handleMouseMove = (event: MouseEvent): void => {
|
||||
if (containerRef.current) {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
isMouseInSection =
|
||||
event.clientX >= rect.left &&
|
||||
event.clientX <= rect.right &&
|
||||
event.clientY >= rect.top &&
|
||||
event.clientY <= rect.bottom;
|
||||
}
|
||||
|
||||
if (isMouseInSection) {
|
||||
mouseX = (event.clientX / window.innerWidth) * 100 - 50;
|
||||
mouseY = (event.clientY / window.innerHeight) * 100 - 50;
|
||||
}
|
||||
};
|
||||
|
||||
const animate = (): void => {
|
||||
if (!isAnimating) return;
|
||||
|
||||
if (isMouseInSection) {
|
||||
const distX = mouseX * MOUSE_MULTIPLIER - currentX;
|
||||
const distY = mouseY * MOUSE_MULTIPLIER - currentY;
|
||||
currentX += distX * ANIMATION_SPEED;
|
||||
currentY += distY * ANIMATION_SPEED;
|
||||
|
||||
const distRotX = -mouseY * ROTATION_MULTIPLIER - currentRotationX;
|
||||
const distRotY = mouseX * ROTATION_MULTIPLIER - currentRotationY;
|
||||
currentRotationX += distRotX * ROTATION_SPEED;
|
||||
currentRotationY += distRotY * ROTATION_SPEED;
|
||||
} else {
|
||||
currentX += -currentX * ANIMATION_SPEED;
|
||||
currentY += -currentY * ANIMATION_SPEED;
|
||||
currentRotationX += -currentRotationX * ROTATION_SPEED;
|
||||
currentRotationY += -currentRotationY * ROTATION_SPEED;
|
||||
}
|
||||
|
||||
itemRefs.current?.forEach((ref) => {
|
||||
if (!ref) return;
|
||||
ref.style.transform = `translate(${currentX}px, ${currentY}px) rotateX(${currentRotationX}deg) rotateY(${currentRotationY}deg)`;
|
||||
});
|
||||
|
||||
animationFrameId = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
animate();
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
}
|
||||
isAnimating = false;
|
||||
};
|
||||
}, [isEnabled, isMobile, itemRefs, containerRef]);
|
||||
|
||||
return { isMobile };
|
||||
return {
|
||||
style: {
|
||||
perspective: perspective + 'px',
|
||||
transformStyle: 'preserve-3d',
|
||||
rotateX,
|
||||
rotateY,
|
||||
scale,
|
||||
transition: transition as Variants<typeof transition>,
|
||||
// The depth effect is implicitly handled by the perspective and rotation transform
|
||||
// Additional translateZ can be added for more explicit depth if needed
|
||||
translateZ: depthFactor + 'px',
|
||||
},
|
||||
onMouseMove: handleMouseMove,
|
||||
onMouseLeave: handleMouseLeave,
|
||||
};
|
||||
};
|
||||
|
||||
1
src/config.ts
Normal file
1
src/config.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const config = {};
|
||||
@@ -1,115 +1,34 @@
|
||||
"use client";
|
||||
import { useState, useEffect } from 'react';
|
||||
import { fetchProducts } from '@/lib/api/product';
|
||||
|
||||
import { useState, useMemo, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useProducts } from "./useProducts";
|
||||
import type { Product } from "@/lib/api/product";
|
||||
import type { CatalogProduct } from "@/components/ecommerce/productCatalog/ProductCatalogItem";
|
||||
import type { ProductVariant } from "@/components/ecommerce/productDetail/ProductDetailCard";
|
||||
|
||||
export type SortOption = "Newest" | "Price: Low-High" | "Price: High-Low";
|
||||
|
||||
interface UseProductCatalogOptions {
|
||||
basePath?: string;
|
||||
interface UseProductCatalogProps {
|
||||
initialProducts?: Product[];
|
||||
category?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export function useProductCatalog(options: UseProductCatalogOptions = {}) {
|
||||
const { basePath = "/shop" } = options;
|
||||
const router = useRouter();
|
||||
const { products: fetchedProducts, isLoading } = useProducts();
|
||||
export const useProductCatalog = ({ initialProducts, category, limit }: UseProductCatalogProps) => {
|
||||
const [products, setProducts] = useState<Product[]>(initialProducts || []);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [category, setCategory] = useState("All");
|
||||
const [sort, setSort] = useState<SortOption>("Newest");
|
||||
|
||||
const handleProductClick = useCallback((productId: string) => {
|
||||
router.push(`${basePath}/${productId}`);
|
||||
}, [router, basePath]);
|
||||
|
||||
const catalogProducts: CatalogProduct[] = useMemo(() => {
|
||||
if (fetchedProducts.length === 0) return [];
|
||||
|
||||
return fetchedProducts.map((product) => ({
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
price: product.price,
|
||||
imageSrc: product.imageSrc,
|
||||
imageAlt: product.imageAlt || product.name,
|
||||
rating: product.rating || 0,
|
||||
reviewCount: product.reviewCount,
|
||||
category: product.brand,
|
||||
onProductClick: () => handleProductClick(product.id),
|
||||
}));
|
||||
}, [fetchedProducts, handleProductClick]);
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const categorySet = new Set<string>();
|
||||
catalogProducts.forEach((product) => {
|
||||
if (product.category) {
|
||||
categorySet.add(product.category);
|
||||
}
|
||||
});
|
||||
return Array.from(categorySet).sort();
|
||||
}, [catalogProducts]);
|
||||
|
||||
const filteredProducts = useMemo(() => {
|
||||
let result = catalogProducts;
|
||||
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
result = result.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
(p.category?.toLowerCase().includes(q) ?? false)
|
||||
);
|
||||
}
|
||||
|
||||
if (category !== "All") {
|
||||
result = result.filter((p) => p.category === category);
|
||||
}
|
||||
|
||||
if (sort === "Price: Low-High") {
|
||||
result = [...result].sort(
|
||||
(a, b) =>
|
||||
parseFloat(a.price.replace("$", "").replace(",", "")) -
|
||||
parseFloat(b.price.replace("$", "").replace(",", ""))
|
||||
);
|
||||
} else if (sort === "Price: High-Low") {
|
||||
result = [...result].sort(
|
||||
(a, b) =>
|
||||
parseFloat(b.price.replace("$", "").replace(",", "")) -
|
||||
parseFloat(a.price.replace("$", "").replace(",", ""))
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [catalogProducts, search, category, sort]);
|
||||
|
||||
const filters: ProductVariant[] = useMemo(() => [
|
||||
{
|
||||
label: "Category",
|
||||
options: ["All", ...categories],
|
||||
selected: category,
|
||||
onChange: setCategory,
|
||||
},
|
||||
{
|
||||
label: "Sort",
|
||||
options: ["Newest", "Price: Low-High", "Price: High-Low"] as SortOption[],
|
||||
selected: sort,
|
||||
onChange: (value) => setSort(value as SortOption),
|
||||
},
|
||||
], [categories, category, sort]);
|
||||
|
||||
return {
|
||||
products: filteredProducts,
|
||||
isLoading,
|
||||
search,
|
||||
setSearch,
|
||||
category,
|
||||
setCategory,
|
||||
sort,
|
||||
setSort,
|
||||
filters,
|
||||
categories,
|
||||
useEffect(() => {
|
||||
const loadProducts = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const fetchedProducts = await fetchProducts({ category, limit });
|
||||
setProducts(fetchedProducts);
|
||||
} catch (err) {
|
||||
setError('Failed to fetch products');
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
}
|
||||
|
||||
if (!initialProducts || initialProducts.length === 0) {
|
||||
loadProducts();
|
||||
}
|
||||
}, [initialProducts, category, limit]);
|
||||
|
||||
return { products, loading, error };
|
||||
};
|
||||
|
||||
@@ -1,196 +1,32 @@
|
||||
"use client";
|
||||
import { useState, useEffect } from 'react';
|
||||
import { fetchProductById } from '@/lib/api/product';
|
||||
|
||||
import { useState, useMemo, useCallback } from "react";
|
||||
import { useProduct } from "./useProduct";
|
||||
import type { Product } from "@/lib/api/product";
|
||||
import type { ProductVariant } from "@/components/ecommerce/productDetail/ProductDetailCard";
|
||||
import type { ExtendedCartItem } from "./useCart";
|
||||
|
||||
interface ProductImage {
|
||||
src: string;
|
||||
alt: string;
|
||||
interface UseProductDetailProps {
|
||||
productId: string;
|
||||
}
|
||||
|
||||
interface ProductMeta {
|
||||
salePrice?: string;
|
||||
ribbon?: string;
|
||||
inventoryStatus?: string;
|
||||
inventoryQuantity?: number;
|
||||
sku?: string;
|
||||
}
|
||||
export const useProductDetail = ({ productId }: UseProductDetailProps) => {
|
||||
const [product, setProduct] = useState<Product | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
export function useProductDetail(productId: string) {
|
||||
const { product, isLoading, error } = useProduct(productId);
|
||||
const [selectedQuantity, setSelectedQuantity] = useState(1);
|
||||
const [selectedVariants, setSelectedVariants] = useState<Record<string, string>>({});
|
||||
|
||||
const images = useMemo<ProductImage[]>(() => {
|
||||
if (!product) return [];
|
||||
|
||||
if (product.images && product.images.length > 0) {
|
||||
return product.images.map((src, index) => ({
|
||||
src,
|
||||
alt: product.imageAlt || `${product.name} - Image ${index + 1}`,
|
||||
}));
|
||||
}
|
||||
return [{
|
||||
src: product.imageSrc,
|
||||
alt: product.imageAlt || product.name,
|
||||
}];
|
||||
}, [product]);
|
||||
|
||||
const meta = useMemo<ProductMeta>(() => {
|
||||
if (!product?.metadata) return {};
|
||||
|
||||
const metadata = product.metadata;
|
||||
|
||||
let salePrice: string | undefined;
|
||||
const onSaleValue = metadata.onSale;
|
||||
const onSale = String(onSaleValue) === "true" || onSaleValue === 1 || String(onSaleValue) === "1";
|
||||
const salePriceValue = metadata.salePrice;
|
||||
|
||||
if (onSale && salePriceValue !== undefined && salePriceValue !== null) {
|
||||
if (typeof salePriceValue === 'number') {
|
||||
salePrice = `$${salePriceValue.toFixed(2)}`;
|
||||
} else {
|
||||
const salePriceStr = String(salePriceValue);
|
||||
salePrice = salePriceStr.startsWith('$') ? salePriceStr : `$${salePriceStr}`;
|
||||
}
|
||||
}
|
||||
|
||||
let inventoryQuantity: number | undefined;
|
||||
if (metadata.inventoryQuantity !== undefined) {
|
||||
const qty = metadata.inventoryQuantity;
|
||||
inventoryQuantity = typeof qty === 'number' ? qty : parseInt(String(qty), 10);
|
||||
}
|
||||
|
||||
return {
|
||||
salePrice,
|
||||
ribbon: metadata.ribbon ? String(metadata.ribbon) : undefined,
|
||||
inventoryStatus: metadata.inventoryStatus ? String(metadata.inventoryStatus) : undefined,
|
||||
inventoryQuantity,
|
||||
sku: metadata.sku ? String(metadata.sku) : undefined,
|
||||
};
|
||||
}, [product]);
|
||||
|
||||
const variants = useMemo<ProductVariant[]>(() => {
|
||||
if (!product) return [];
|
||||
|
||||
const variantList: ProductVariant[] = [];
|
||||
|
||||
if (product.metadata?.variantOptions) {
|
||||
try {
|
||||
const variantOptionsStr = String(product.metadata.variantOptions);
|
||||
const parsedOptions = JSON.parse(variantOptionsStr);
|
||||
|
||||
if (Array.isArray(parsedOptions)) {
|
||||
parsedOptions.forEach((option: any) => {
|
||||
if (option.name && option.values) {
|
||||
const values = typeof option.values === 'string'
|
||||
? option.values.split(',').map((v: string) => v.trim())
|
||||
: Array.isArray(option.values)
|
||||
? option.values.map((v: any) => String(v).trim())
|
||||
: [String(option.values)];
|
||||
|
||||
if (values.length > 0) {
|
||||
const optionLabel = option.name;
|
||||
const currentSelected = selectedVariants[optionLabel] || values[0];
|
||||
|
||||
variantList.push({
|
||||
label: optionLabel,
|
||||
options: values,
|
||||
selected: currentSelected,
|
||||
onChange: (value) => {
|
||||
setSelectedVariants((prev) => ({
|
||||
...prev,
|
||||
[optionLabel]: value,
|
||||
}));
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to parse variantOptions:", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (variantList.length === 0 && product.brand) {
|
||||
variantList.push({
|
||||
label: "Brand",
|
||||
options: [product.brand],
|
||||
selected: product.brand,
|
||||
onChange: () => { },
|
||||
});
|
||||
}
|
||||
|
||||
if (variantList.length === 0 && product.variant) {
|
||||
const variantOptions = product.variant.includes('/')
|
||||
? product.variant.split('/').map(v => v.trim())
|
||||
: [product.variant];
|
||||
|
||||
const variantLabel = "Variant";
|
||||
const currentSelected = selectedVariants[variantLabel] || variantOptions[0];
|
||||
|
||||
variantList.push({
|
||||
label: variantLabel,
|
||||
options: variantOptions,
|
||||
selected: currentSelected,
|
||||
onChange: (value) => {
|
||||
setSelectedVariants((prev) => ({
|
||||
...prev,
|
||||
[variantLabel]: value,
|
||||
}));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return variantList;
|
||||
}, [product, selectedVariants]);
|
||||
|
||||
const quantityVariant = useMemo<ProductVariant>(() => ({
|
||||
label: "Quantity",
|
||||
options: Array.from({ length: 10 }, (_, i) => String(i + 1)),
|
||||
selected: String(selectedQuantity),
|
||||
onChange: (value) => setSelectedQuantity(parseInt(value, 10)),
|
||||
}), [selectedQuantity]);
|
||||
|
||||
const createCartItem = useCallback((): ExtendedCartItem | null => {
|
||||
if (!product) return null;
|
||||
|
||||
const variantStrings = Object.entries(selectedVariants).map(
|
||||
([label, value]) => `${label}: ${value}`
|
||||
);
|
||||
|
||||
if (variantStrings.length === 0 && product.variant) {
|
||||
variantStrings.push(`Variant: ${product.variant}`);
|
||||
}
|
||||
|
||||
const variantId = Object.values(selectedVariants).join('-') || 'default';
|
||||
|
||||
return {
|
||||
id: `${product.id}-${variantId}-${selectedQuantity}`,
|
||||
productId: product.id,
|
||||
name: product.name,
|
||||
variants: variantStrings,
|
||||
price: product.price,
|
||||
quantity: selectedQuantity,
|
||||
imageSrc: product.imageSrc,
|
||||
imageAlt: product.imageAlt || product.name,
|
||||
};
|
||||
}, [product, selectedVariants, selectedQuantity]);
|
||||
|
||||
return {
|
||||
product,
|
||||
isLoading,
|
||||
error,
|
||||
images,
|
||||
meta,
|
||||
variants,
|
||||
quantityVariant,
|
||||
selectedQuantity,
|
||||
selectedVariants,
|
||||
createCartItem,
|
||||
useEffect(() => {
|
||||
const loadProduct = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const fetchedProduct = await fetchProductById(productId);
|
||||
setProduct(fetchedProduct);
|
||||
} catch (err) {
|
||||
setError('Failed to fetch product details');
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
}
|
||||
|
||||
if (productId) {
|
||||
loadProduct();
|
||||
}
|
||||
}, [productId]);
|
||||
|
||||
return { product, loading, error };
|
||||
};
|
||||
|
||||
27
src/i18n.ts
Normal file
27
src/i18n.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// @ts-expect-error Missing declaration file
|
||||
import i18n from 'i18next';
|
||||
// @ts-expect-error Missing declaration file
|
||||
import Backend from 'i18next-http-backend';
|
||||
// @ts-expect-error Missing declaration file
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
// @ts-expect-error Missing declaration file
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
|
||||
i18n
|
||||
.use(Backend)
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
fallbackLng: 'en',
|
||||
debug: false,
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
backend: {
|
||||
loadPath: '/locales/{{lng}}/{{ns}}.json',
|
||||
},
|
||||
ns: ['common', 'home'],
|
||||
defaultNS: 'common',
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
Reference in New Issue
Block a user