diff --git a/src/app/[name]/(Dialogs)/cart/components/CartSummary.tsx b/src/app/[name]/(Dialogs)/cart/components/CartSummary.tsx index ea5dd89..95bba2d 100644 --- a/src/app/[name]/(Dialogs)/cart/components/CartSummary.tsx +++ b/src/app/[name]/(Dialogs)/cart/components/CartSummary.tsx @@ -23,20 +23,40 @@ const CartSummary = ({ isPremium }: CartSummaryProps) => { const name = params.name as string; const { isSuccess } = useGetProfile(); const { data: productsResponse } = useGetProducts(); - const products = React.useMemo(() => productsResponse?.data ?? [], [productsResponse?.data]); + const products = React.useMemo( + () => productsResponse?.data ?? [], + [productsResponse?.data] + ); const { items } = useCart(); const { data: cartData } = useGetCartItems(isSuccess); - const { t } = useTranslation('parallels', { keyPrefix: 'Cart' }); + const { t } = useTranslation("parallels", { keyPrefix: "Cart" }); const { description, setDescription } = useCartStore(); - const cartProducts = React.useMemo(() => { - if (!products.length) return []; - return Object.entries(items) + const cartEntries = React.useMemo(() => { + if (products.length === 0) return []; + + const result: { product: Product; variantId: string; quantity: number }[] = + []; + + Object.entries(items) .filter(([, detail]) => detail?.quantity && detail.quantity > 0) - .map(([id]) => - products.find((productItem) => String(productItem.id) === String(id)) - ) - .filter((product): product is Product => Boolean(product)); + .forEach(([variantId, detail]) => { + const product = products.find((productItem) => + productItem.variants?.some( + (variant) => String(variant.id) === String(variantId) + ) + ); + + if (!product) return; + + result.push({ + product, + variantId: String(variantId), + quantity: detail.quantity, + }); + }); + + return result; }, [products, items]); const totalPrice = React.useMemo(() => { @@ -45,17 +65,19 @@ const CartSummary = ({ isPremium }: CartSummaryProps) => { return cartData.data.total; } - // در غیر این صورت، محاسبه محلی با در نظر گرفتن تخفیف - return cartProducts.reduce((sum, product) => { - const qty = items[String(product.id)]?.quantity ?? 0; - const basePrice = product.price ?? 0; - const discount = product.discount ?? 0; + // در غیر این صورت، محاسبه محلی با در نظر گرفتن تخفیف و تنوع‌ها + return cartEntries.reduce((sum, entry) => { + const variant = entry.product.variants?.find( + (v) => String(v.id) === entry.variantId + ); + const basePrice = variant?.price ?? entry.product.price ?? 0; + const discount = entry.product.discount ?? 0; const priceAfterDiscount = basePrice * (1 - discount / 100); - return sum + priceAfterDiscount * qty; + return sum + priceAfterDiscount * entry.quantity; }, 0); - }, [cartProducts, items, isSuccess, cartData?.data?.total]); + }, [cartEntries, isSuccess, cartData?.data?.total]); - const isCartEmpty = cartProducts.length === 0; + const isCartEmpty = cartEntries.length === 0; const formatPrice = React.useCallback( (value: number) => value.toLocaleString('fa-IR'), @@ -96,7 +118,7 @@ const CartSummary = ({ isPremium }: CartSummaryProps) => { {isPremium && ( { if (!isSuccess) { event.preventDefault(); @@ -106,7 +128,9 @@ const CartSummary = ({ isPremium }: CartSummaryProps) => { } }} > - + )} {!isPremium && ( diff --git a/src/app/[name]/(Dialogs)/cart/hooks/useCartData.ts b/src/app/[name]/(Dialogs)/cart/hooks/useCartData.ts index c268351..71e6e7a 100644 --- a/src/app/[name]/(Dialogs)/cart/hooks/useCartData.ts +++ b/src/app/[name]/(Dialogs)/cart/hooks/useCartData.ts @@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import * as api from "../service/CartService"; import type { CartResponse, CartItem } from "../types/Types"; import type { ProductsResponse } from "@/app/[name]/(Main)/types/Types"; +import { getProductEffectivePrice } from "@/app/[name]/(Main)/types/Types"; export const useBulkCart = () => { const queryClient = useQueryClient(); @@ -47,15 +48,22 @@ export const useIncrementCart = () => { }); newTotalItems = previousCart.data.totalItems + 1; } else { - const product = productsData.data.find((f) => String(f.id) === String(id)); - if (product) { + const variantId = String(id); + const product = productsData.data.find((p) => + p.variants?.some((v) => v.id === variantId) + ); + const variant = product?.variants?.find((v) => v.id === variantId); + if (product && variant) { + const effectivePrice = getProductEffectivePrice(product); + const price = variant.price ?? effectivePrice; + const discount = product.discount ?? 0; const newItem: CartItem = { - productId: String(id), + productId: variantId, productTitle: product.title || product.name || "", quantity: 1, - price: product.price, - discount: product.discount || 0, - totalPrice: product.price * (1 - (product.discount || 0) / 100), + price, + discount, + totalPrice: price * (1 - discount / 100), }; updatedItems = [...previousCart.data.items, newItem]; newTotalItems = previousCart.data.totalItems + 1; diff --git a/src/app/[name]/(Dialogs)/cart/page.tsx b/src/app/[name]/(Dialogs)/cart/page.tsx index 9b42948..da6e23e 100644 --- a/src/app/[name]/(Dialogs)/cart/page.tsx +++ b/src/app/[name]/(Dialogs)/cart/page.tsx @@ -26,10 +26,13 @@ const CartIndex = () => { if (!products.length) return []; return Object.entries(items) .filter(([, detail]) => detail?.quantity && detail.quantity > 0) - .map(([id]) => - products.find((productItem) => String(productItem.id) === String(id)) - ) - .filter((product): product is Product => Boolean(product)); + .map(([variantId]) => { + const product = products.find((p) => + p.variants?.some((v) => v.id === variantId) + ); + return product ? { product, variantId } : null; + }) + .filter((x): x is { product: Product; variantId: string } => Boolean(x)); }, [products, items]); const isCartEmpty = cartProducts.length === 0; @@ -95,9 +98,9 @@ const CartIndex = () => { ) : (
- {cartProducts.map((product) => ( - - + {cartProducts.map(({ product, variantId }) => ( + + ))} diff --git a/src/app/[name]/(Dialogs)/cart/service/CartService.ts b/src/app/[name]/(Dialogs)/cart/service/CartService.ts index 11625f7..fabd421 100644 --- a/src/app/[name]/(Dialogs)/cart/service/CartService.ts +++ b/src/app/[name]/(Dialogs)/cart/service/CartService.ts @@ -7,12 +7,12 @@ export const bulkCart = async (params: BulkCartItem) => { }; export const increment = async (id: string | number) => { - const { data } = await api.post(`/public/cart/product/${id}/increment`); + const { data } = await api.post(`/public/cart/variant/${id}/increment`); return data; }; export const decrement = async (id: string | number) => { - const { data } = await api.post(`/public/cart/product/${id}/decrement`); + const { data } = await api.post(`/public/cart/variant/${id}/decrement`); return data; }; diff --git a/src/app/[name]/(Dialogs)/cart/types/Types.ts b/src/app/[name]/(Dialogs)/cart/types/Types.ts index 473275c..251c6e8 100644 --- a/src/app/[name]/(Dialogs)/cart/types/Types.ts +++ b/src/app/[name]/(Dialogs)/cart/types/Types.ts @@ -2,12 +2,13 @@ import { BaseResponse } from "@/app/[name]/(Main)/types/Types"; export type BulkCartItem = { items: { - productId: string; + variantId: string; quantity: number; }[]; }; export interface CartItem { + /** شناسه واریانت (در API ممکن است هنوز productId نامیده شود) */ productId: string; productTitle: string; quantity: number; diff --git a/src/app/[name]/(Main)/[id]/page.tsx b/src/app/[name]/(Main)/[id]/page.tsx index c6b78d8..c7d6abe 100644 --- a/src/app/[name]/(Main)/[id]/page.tsx +++ b/src/app/[name]/(Main)/[id]/page.tsx @@ -26,26 +26,28 @@ function ProductPage({ }: Props) { const { data: about } = useGetAbout(); const { items, addToCart, removeFromCart } = useCart(); const [isFavorite, setIsFavorite] = useState(false); + const [selectedVariantId, setSelectedVariantId] = useState(null); const { mutate: toggleFavorite } = useToggleFavorite(); const productId = product?.data?.id || id as string; const quantity = useMemo(() => { - const item = items?.[productId]; + if (!selectedVariantId) return 0; + const item = items?.[selectedVariantId]; if (item && typeof item === 'object' && 'quantity' in item) { return item.quantity || 0; } return 0; - }, [items, productId]); + }, [items, selectedVariantId]); const handleAddToCart = () => { - if (productId) { - addToCart(productId); + if (selectedVariantId) { + addToCart(selectedVariantId); } }; const handleRemoveFromCart = () => { - if (productId) { - removeFromCart(productId); + if (selectedVariantId) { + removeFromCart(selectedVariantId); } }; @@ -75,12 +77,20 @@ function ProductPage({ }: Props) { }; useEffect(() => { - if (product?.data?.isFavorite) { - setIsFavorite(true); - } else { - setIsFavorite(false); + const typed = product as unknown as { data?: { isFavorite?: boolean } }; + const isFav = Boolean(typed?.data?.isFavorite); + setIsFavorite(isFav); + }, [product]); + + useEffect(() => { + if (!product?.data) return; + const variants = product.data.variants ?? []; + if (!variants.length) { + setSelectedVariantId(null); + return; } - }, [product?.data?.isFavorite]); + setSelectedVariantId((prev) => prev ?? variants[0].id); + }, [product?.data]); if (isLoading) { return ( @@ -99,7 +109,7 @@ function ProductPage({ }: Props) { } const productData = product.data; - const productName = productData.title || productData.name || productData.foodName || productData.productName || ''; + const productName = productData.title || productData.name || ''; const productImage = typeof productData.image === 'string' ? productData.image : Array.isArray(productData.images) && productData.images.length > 0 @@ -110,8 +120,15 @@ function ProductPage({ }: Props) { : '/assets/images/no-image.png' : '/assets/images/no-image.png'; const categoryName = productData.category?.title; + const variants = productData.variants ?? []; + const hasVariants = Boolean(productData.attribute?.trim()); + const selectedVariant = selectedVariantId + ? variants.find((v) => v.id === selectedVariantId) ?? variants[0] + : variants[0]; + const price = hasVariants + ? (selectedVariant?.price ?? 0) + : (productData.price ?? selectedVariant?.price ?? 0); const prepareTime = productData.prepareTime || 0; - const price = productData.price || 0; const content = Array.isArray(productData.content) ? productData.content.join('، ') : productData.content || productData.desc || ''; @@ -186,7 +203,7 @@ function ProductPage({ }: Props) {
{ef( Math.round( - productData?.price * + price * (Number(about.data.score.purchaseScore) / Number(about.data.score.purchaseAmount)) ).toLocaleString('en-US') )} @@ -211,6 +228,28 @@ function ProductPage({ }: Props) {

{content || '-'}

+ {hasVariants && variants.length > 0 && ( +
+

{productData.attribute}

+
+ {variants.map((variant) => ( + + ))} +
+
+ )} +
{ef(price.toLocaleString('en-US'))} T ; export type ProductImage = string | { url: string; alt?: string | null }; -export interface ShopRef { +/** Category as nested in product list API */ +export interface ProductCategory { id: string; + createdAt: string; + updatedAt: string; + deletedAt: string | null; + parent: string | null; + title: string; + isActive: boolean; + shop: string; + avatarUrl: string; + order: number; } -export interface InventoryRef { +export interface ProductVariant { id: string; + createdAt: string; + updatedAt: string; + deletedAt: string | null; + product: string; + value: string; + price: number; } -export type MealType = "breakfast" | "lunch" | "dinner" | "snack"; - export interface Product { id: string; createdAt: string; updatedAt: string; deletedAt: string | null; - restaurant: ShopRef; - category: Category; - inventory: InventoryRef; + shop: string; + category: ProductCategory; + attribute: string; title: string; desc: string; - content: string[]; - price: number; - order: number | null; - prepareTime: number | null; - weekDays: number[]; - mealTypes: MealType[]; + order: number; isActive: boolean; images: string[]; - inPlaceServe: boolean; - pickupServe: boolean; - score: number; + score: number | null; discount: number; isSpecialOffer: boolean; - reviews: unknown[]; - favorites: unknown[]; - isFavorite: boolean; - // فیلدهای قدیمی برای سازگاری با API + price: number | null; + variants: ProductVariant[]; + /** Backward compatibility / other APIs */ name?: string; - foodName?: string; - productName?: string; description?: string; + content?: string[] | string | null; image?: string | null; + inPlaceServe?: boolean; + pickupServe?: boolean; points?: number | null; - sat?: boolean; - sun?: boolean; - mon?: boolean; - breakfast?: boolean; - noon?: boolean; - dinner?: boolean; - stock?: number; - stockDefault?: number; - rate?: number; - [key: string]: unknown; + rate?: number | null; + prepareTime?: number | null; + isFavorite?: boolean; } export type ProductsResponse = BaseResponse; export type ProductResponse = BaseResponse; +/** محصول بدون تنوع: attribute خالی → قیمت از اولین واریانت */ +export function hasVariants(product: Product): boolean { + return Boolean(product.attribute?.trim()); +} + +/** شناسه‌ای که برای سبد/API استفاده می‌شود: همیشه variantId (اگر بدون تنوع، همان واریانت اول) */ +export function getPrimaryVariantId(product: Product): string | null { + const first = product.variants?.[0]; + return first?.id ?? null; +} + +/** قیمت مؤثر: بدون تنوع = قیمت واریانت اول، با تنوع = product.price یا اولین واریانت */ +export function getProductEffectivePrice(product: Product): number { + if (product.price != null && product.price > 0) return product.price; + const first = product.variants?.[0]; + return first?.price ?? 0; +} + export interface NotificationsCountData { count: number; } diff --git a/src/app/[name]/(Profile)/favorite/page.tsx b/src/app/[name]/(Profile)/favorite/page.tsx index 1943e4c..7608651 100644 --- a/src/app/[name]/(Profile)/favorite/page.tsx +++ b/src/app/[name]/(Profile)/favorite/page.tsx @@ -8,7 +8,7 @@ import MenuItem from '@/components/listview/MenuItem' import MenuItemRenderer from '@/components/listview/MenuItemRenderer' import VerticalScrollView from '@/components/listview/VerticalScrollView' import type { Favorite } from './types/Types' -import type { Product } from '@/app/[name]/(Main)/types/Types' +import type { Product, ProductCategory, ProductVariant } from '@/app/[name]/(Main)/types/Types' function FavoritePage() { const router = useRouter() @@ -22,22 +22,40 @@ function FavoritePage() { return favorites.map((favorite: Favorite) => { const favProduct = favorite.food // تبدیل FavoriteProduct به Product برای استفاده در MenuItem + const category: ProductCategory = { + id: favProduct.category, + createdAt: '', + updatedAt: '', + deletedAt: null, + parent: null, + title: '', + isActive: true, + shop: '', + avatarUrl: '', + order: 0, + } + const singleVariant: ProductVariant = { + id: favProduct.id, + createdAt: favProduct.createdAt, + updatedAt: favProduct.updatedAt, + deletedAt: favProduct.deletedAt, + product: favProduct.id, + value: '', + price: favProduct.price, + } const product: Product = { id: favProduct.id, createdAt: favProduct.createdAt, updatedAt: favProduct.updatedAt, deletedAt: favProduct.deletedAt, - restaurant: favProduct.restaurant as unknown as Product['restaurant'], - category: favProduct.category as unknown as Product['category'], - inventory: {} as Product['inventory'], + shop: favProduct.restaurant, + category, + attribute: '', title: favProduct.title, desc: favProduct.desc, content: favProduct.content, price: favProduct.price, order: favProduct.order, - prepareTime: favProduct.prepareTime, - weekDays: favProduct.weekDays, - mealTypes: favProduct.mealTypes, isActive: favProduct.isActive, images: favProduct.images, inPlaceServe: favProduct.inPlaceServe, @@ -45,11 +63,9 @@ function FavoritePage() { score: favProduct.score, discount: favProduct.discount, isSpecialOffer: favProduct.isSpecialOffer, - reviews: [], - favorites: [], - isFavorite: true, name: favProduct.title, description: favProduct.desc, + variants: [singleVariant], } return product }) diff --git a/src/components/listview/MenuItem.tsx b/src/components/listview/MenuItem.tsx index 99a23f4..7154d67 100644 --- a/src/components/listview/MenuItem.tsx +++ b/src/components/listview/MenuItem.tsx @@ -9,23 +9,38 @@ import MinusIcon from "@/components/icons/MinusIcon"; import { useCart } from "@/app/[name]/(Dialogs)/cart/hook/useCart"; import { motion } from "framer-motion"; import type { Product, ProductImage } from "@/app/[name]/(Main)/types/Types"; +import { + hasVariants, + getPrimaryVariantId, + getProductEffectivePrice, +} from "@/app/[name]/(Main)/types/Types"; interface MenuItemProps { product: Product; + /** در صفحه سبد هر خط با variantId مشخص می‌شود */ + variantId?: string | null; } -const MenuItem = ({ product }: MenuItemProps) => { +const MenuItem = ({ product, variantId: variantIdProp }: MenuItemProps) => { const { items, addToCart, removeFromCart } = useCart(); const params = useParams<{ name: string }>(); - const name = params?.name || ''; + const name = params?.name || ""; + const variantId = variantIdProp ?? getPrimaryVariantId(product); + const selectedVariant = variantId + ? product.variants?.find((v) => v.id === variantId) + : undefined; + const withVariants = hasVariants(product); + /** در لیست منو اگر تنوع داشت دکمه جزئیات؛ در سبد همیشه افزودن/کم کردن */ + const showDetailsInsteadOfAdd = withVariants && variantIdProp == null; const quantity = useMemo(() => { - const item = items?.[product.id]; - if (item && typeof item === 'object' && 'quantity' in item) { + if (!variantId) return 0; + const item = items?.[variantId]; + if (item && typeof item === "object" && "quantity" in item) { return item.quantity || 0; } return 0; - }, [items, product.id]); + }, [items, variantId]); const fallbackImage = "/assets/images/no-image.png"; const resolvedImage = useMemo(() => { @@ -40,8 +55,8 @@ const MenuItem = ({ product }: MenuItemProps) => { }, [product.image, product.images]); const productName = useMemo( - () => product.name || product.title || product.foodName || product.productName || 'بدون نام', - [product.name, product.title, product.foodName, product.productName] + () => product.name || product.title || 'بدون نام', + [product.name, product.title] ); const productContent = useMemo(() => { @@ -61,23 +76,27 @@ const MenuItem = ({ product }: MenuItemProps) => { return desc.replace(/,/g, '،'); }, [product.description, product.desc]); + const effectivePrice = + selectedVariant != null + ? selectedVariant.price + : getProductEffectivePrice(product); const finalPrice = useMemo(() => { - const basePrice = product.price || 0; const discount = product.discount || 0; if (discount > 0) { - return Math.max(0, basePrice - discount); + return Math.max(0, effectivePrice - discount); } - return basePrice; - }, [product.price, product.discount]); + return effectivePrice; + }, [effectivePrice, product.discount]); const formattedPrice = useMemo( - () => (finalPrice ? finalPrice.toLocaleString('fa-IR') : '0'), + () => (finalPrice ? finalPrice.toLocaleString("fa-IR") : "0"), [finalPrice] ); const formattedOriginalPrice = useMemo( - () => (product.price ? product.price.toLocaleString('fa-IR') : '0'), - [product.price] + () => + effectivePrice ? effectivePrice.toLocaleString("fa-IR") : "0", + [effectivePrice] ); const hasDiscount = useMemo( @@ -86,11 +105,11 @@ const MenuItem = ({ product }: MenuItemProps) => { ); const handleAddToCart = () => { - addToCart(product.id); + if (variantId) addToCart(variantId); }; const handleRemoveFromCart = () => { - removeFromCart(product.id); + if (variantId) removeFromCart(variantId); }; const productDetailUrl = `/${name}/${product.id}?category=${product.category.id}`; @@ -136,47 +155,55 @@ const MenuItem = ({ product }: MenuItemProps) => { )}
- - {quantity <= 0 ? ( - - ) : ( - <> + {showDetailsInsteadOfAdd ? ( + + جزئیات + + ) : ( + + {quantity <= 0 ? ( - - {quantity} - - - - )} - + ) : ( + <> + + + {quantity} + + + + )} + + )}
diff --git a/src/features/auth/components/StepOtp.tsx b/src/features/auth/components/StepOtp.tsx index 10a24fb..ce34d13 100644 --- a/src/features/auth/components/StepOtp.tsx +++ b/src/features/auth/components/StepOtp.tsx @@ -52,15 +52,15 @@ const StepOtp = ({ phone, slug }: Props) => { setRefreshToken(data?.data?.tokens?.refreshToken?.token as string) if (Object.keys(items).length > 0) { - const bulkCartItems = Object.entries(items).map(([key, value]) => { + const bulkCartItems = Object.entries(items).map(([variantId, value]) => { if (value.quantity > 0) { return { - productId: key, + variantId, quantity: value.quantity } } return undefined - }).filter((item): item is { productId: string; quantity: number } => item !== undefined) + }).filter((item): item is { variantId: string; quantity: number } => item !== undefined) if (!!bulkCartItems.length) { mutateBulkCart({ items: bulkCartItems }, { onSuccess: () => {