feat(product): mobile redesign — brand below image, floating back, circular colors
Per design feedback, restructure the mobile product page (desktop layout unchanged except color swatches): - remove the top seller bar; the seller/brand (logo + name + ارسال پیام) now sits in a row below the gallery. - add a floating circular back button over the image (safe-area aware). - move the rating out of the action row into a prominent body row, shown only when the average rating is > 0. - color swatches are now circles with a selection ring (shared with desktop for consistency) instead of squares. - make the افزودن به سبد خرید button full-width/taller with a compact price beside it. - the action row keeps save / share / add-to-collection / try-on. - the red discount icon is kept as-is. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
b45e247dda
commit
692d385d09
@ -62,8 +62,8 @@ interface AddToCartButtonProps {
|
|||||||
const AddToCartButton = ({ isOutOfStock, onClick }: AddToCartButtonProps) => (
|
const AddToCartButton = ({ isOutOfStock, onClick }: AddToCartButtonProps) => (
|
||||||
<Button
|
<Button
|
||||||
variant="dark"
|
variant="dark"
|
||||||
size="lg"
|
size="xl"
|
||||||
className="font-bold flex items-center gap-2 bg-VITROWN_BLUE text-white"
|
className="flex-1 h-[52px] rounded-[14px] text-B14 font-bold flex items-center justify-center gap-2 bg-VITROWN_BLUE text-white"
|
||||||
disabled={isOutOfStock}
|
disabled={isOutOfStock}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
@ -71,7 +71,7 @@ const AddToCartButton = ({ isOutOfStock, onClick }: AddToCartButtonProps) => (
|
|||||||
<span className="text-B14 font-bold">ناموجود</span>
|
<span className="text-B14 font-bold">ناموجود</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ShoppingCart size={24} />
|
<ShoppingCart size={22} />
|
||||||
افزودن به سبد خرید
|
افزودن به سبد خرید
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -194,19 +194,12 @@ export const BottomBar = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="max-w-md mx-auto fixed gap-2 bottom-[calc(50px+env(safe-area-inset-bottom))] left-0 right-0 border-t border-inner-border bg-WHITE p-4 z-50 flex flex-row lg:static lg:max-w-none lg:mx-0 lg:border lg:rounded-xl lg:mt-6 lg:z-auto">
|
<div className="max-w-md mx-auto fixed gap-3 items-center bottom-[calc(50px+env(safe-area-inset-bottom))] left-0 right-0 border-t border-inner-border bg-WHITE p-4 z-50 flex flex-row lg:static lg:max-w-none lg:mx-0 lg:border lg:rounded-xl lg:mt-6 lg:z-auto">
|
||||||
<div className="flex w-full items-center justify-between">
|
<PriceDisplay
|
||||||
<div className="flex flex-col gap-1">
|
currentPrice={currentPrice}
|
||||||
<AddToCartButton
|
currentDiscountPrice={currentDiscountPrice}
|
||||||
isOutOfStock={isOutOfStock}
|
/>
|
||||||
onClick={handleAddToCart}
|
<AddToCartButton isOutOfStock={isOutOfStock} onClick={handleAddToCart} />
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<PriceDisplay
|
|
||||||
currentPrice={currentPrice}
|
|
||||||
currentDiscountPrice={currentDiscountPrice}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CartDrawer
|
<CartDrawer
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
import { Check } from "lucide-react";
|
|
||||||
|
|
||||||
interface ColorSelectorProps {
|
interface ColorSelectorProps {
|
||||||
colors: string[] | undefined;
|
colors: string[] | undefined;
|
||||||
selectedColor: string | null;
|
selectedColor: string | null;
|
||||||
@ -12,24 +10,23 @@ export const ColorSelector = ({
|
|||||||
setSelectedColor,
|
setSelectedColor,
|
||||||
}: ColorSelectorProps) => {
|
}: ColorSelectorProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 mt-10 px-4">
|
<div className="flex flex-col gap-4 mt-10 px-4 lg:px-0">
|
||||||
<p className="text-B18 font-bold">انتخاب رنگ</p>
|
<p className="text-B18 font-bold">انتخاب رنگ</p>
|
||||||
<div className="gap-4 flex">
|
<div className="gap-4 flex">
|
||||||
{colors?.map((enColor: string, index: number) => {
|
{colors?.map((enColor: string, index: number) => {
|
||||||
|
const isSelected = selectedColor === enColor;
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
key={index}
|
key={index}
|
||||||
className="w-10 border h-10 rounded-md flex items-center justify-center"
|
type="button"
|
||||||
|
aria-label={`رنگ ${index + 1}`}
|
||||||
|
aria-pressed={isSelected}
|
||||||
|
className={`w-9 h-9 rounded-full border-2 border-WHITE transition-shadow ring-offset-2 ring-offset-WHITE ${
|
||||||
|
isSelected ? "ring-2 ring-BLACK" : "ring-1 ring-WHITE3"
|
||||||
|
}`}
|
||||||
style={{ backgroundColor: enColor }}
|
style={{ backgroundColor: enColor }}
|
||||||
onClick={() => setSelectedColor(enColor)}
|
onClick={() => setSelectedColor(enColor)}
|
||||||
onKeyDown={() => {}}
|
/>
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
>
|
|
||||||
{selectedColor === enColor && (
|
|
||||||
<Check size={24} className="text-WHITE" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
Send,
|
Send,
|
||||||
Share2,
|
Share2,
|
||||||
Star,
|
|
||||||
X,
|
X,
|
||||||
Loader2,
|
Loader2,
|
||||||
Copy,
|
Copy,
|
||||||
@ -20,8 +19,6 @@ import {
|
|||||||
} from "~/components/ui/drawer";
|
} from "~/components/ui/drawer";
|
||||||
import { Skeleton } from "~/components/ui/skeleton";
|
import { Skeleton } from "~/components/ui/skeleton";
|
||||||
import {
|
import {
|
||||||
useServiceGetProductRating,
|
|
||||||
useServiceGetComments,
|
|
||||||
useServiceBookmarkProduct,
|
useServiceBookmarkProduct,
|
||||||
useServiceDeleteBookmark,
|
useServiceDeleteBookmark,
|
||||||
useServiceCheckBookmark,
|
useServiceCheckBookmark,
|
||||||
@ -62,9 +59,6 @@ export const ProductActions = ({ productId }: { productId: string }) => {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { user } = useRootData();
|
const { user } = useRootData();
|
||||||
|
|
||||||
const { data: ratingData, isLoading: ratingLoading } =
|
|
||||||
useServiceGetProductRating(productId || "");
|
|
||||||
const { data: commentsData } = useServiceGetComments(1, productId || "");
|
|
||||||
const { data: bookmarkData, isLoading: isCheckingBookmark } =
|
const { data: bookmarkData, isLoading: isCheckingBookmark } =
|
||||||
useServiceCheckBookmark(productId || "");
|
useServiceCheckBookmark(productId || "");
|
||||||
const { mutate: bookmarkProduct, isPending: isBookmarking } =
|
const { mutate: bookmarkProduct, isPending: isBookmarking } =
|
||||||
@ -73,8 +67,6 @@ export const ProductActions = ({ productId }: { productId: string }) => {
|
|||||||
useServiceDeleteBookmark();
|
useServiceDeleteBookmark();
|
||||||
|
|
||||||
const isBookmarked = bookmarkData?.bookmarked === true;
|
const isBookmarked = bookmarkData?.bookmarked === true;
|
||||||
const averageRating = ratingData?.averageRating || 0;
|
|
||||||
const commentCount = commentsData?.count || 0;
|
|
||||||
|
|
||||||
const handleBookmark = () => {
|
const handleBookmark = () => {
|
||||||
// Check if user is logged in
|
// Check if user is logged in
|
||||||
@ -165,7 +157,7 @@ export const ProductActions = ({ productId }: { productId: string }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex w-full mt-4 px-4 justify-between lg:mt-0 lg:px-0">
|
<div className="flex w-full mt-4 px-4 lg:mt-0 lg:px-0">
|
||||||
<div className="flex gap-8 lg:gap-2.5">
|
<div className="flex gap-8 lg:gap-2.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -237,17 +229,6 @@ export const ProductActions = ({ productId }: { productId: string }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 items-center">
|
|
||||||
<div className="flex gap-1 items-center">
|
|
||||||
{!ratingLoading && averageRating > 0 && (
|
|
||||||
<>
|
|
||||||
<p className="text-B12 font-bold text-GRAY">({commentCount})</p>
|
|
||||||
<p className="text-B12 font-bold">{averageRating.toFixed(1)}</p>
|
|
||||||
<Star className="text-BLACK fill-current" size={20} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Drawer open={isShareDrawerOpen} onOpenChange={setIsShareDrawerOpen}>
|
<Drawer open={isShareDrawerOpen} onOpenChange={setIsShareDrawerOpen}>
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { useState, useEffect, useMemo, memo } from "react";
|
import { useState, useEffect, useMemo, memo } from "react";
|
||||||
import {
|
import {
|
||||||
ProductHeader,
|
|
||||||
ProductImageCarousel,
|
ProductImageCarousel,
|
||||||
ProductActions,
|
ProductActions,
|
||||||
ProductInfo,
|
ProductInfo,
|
||||||
@ -12,6 +11,7 @@ import {
|
|||||||
} from "./index";
|
} from "./index";
|
||||||
import { ProductDetail as ProductDetailType } from "../../../src/api/types";
|
import { ProductDetail as ProductDetailType } from "../../../src/api/types";
|
||||||
import {
|
import {
|
||||||
|
ArrowRight,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Heart,
|
Heart,
|
||||||
@ -37,7 +37,7 @@ import {
|
|||||||
} from "~/utils/RequestHandler";
|
} from "~/utils/RequestHandler";
|
||||||
import { useAddToCart } from "~/requestHandler/use-cart-hooks";
|
import { useAddToCart } from "~/requestHandler/use-cart-hooks";
|
||||||
import { useCreateChatThread } from "~/requestHandler/use-chat-hooks";
|
import { useCreateChatThread } from "~/requestHandler/use-chat-hooks";
|
||||||
import { findVariantId, formatPrice } from "~/utils/helpers";
|
import { findVariantId, formatPrice, safeGoBack } from "~/utils/helpers";
|
||||||
import { useToast } from "~/hooks/use-toast";
|
import { useToast } from "~/hooks/use-toast";
|
||||||
import { useRootData } from "~/hooks/use-root-data";
|
import { useRootData } from "~/hooks/use-root-data";
|
||||||
import { LoginRequiredDialog } from "../LoginRequiredDialog";
|
import { LoginRequiredDialog } from "../LoginRequiredDialog";
|
||||||
@ -325,12 +325,6 @@ export const ProductDetailView = memo(function ProductDetailView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col pb-20 lg:max-w-[1320px] lg:mx-auto lg:w-full lg:px-8 lg:pb-12">
|
<div className="flex flex-col pb-20 lg:max-w-[1320px] lg:mx-auto lg:w-full lg:px-8 lg:pb-12">
|
||||||
{!isOwner && (
|
|
||||||
<div className="lg:hidden">
|
|
||||||
<ProductHeader product={product} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Desktop breadcrumb */}
|
{/* Desktop breadcrumb */}
|
||||||
<nav className="hidden lg:flex items-center gap-2 text-[13px] text-GRAY mt-4 mb-4">
|
<nav className="hidden lg:flex items-center gap-2 text-[13px] text-GRAY mt-4 mb-4">
|
||||||
<Link to="/" className="hover:text-BLACK">
|
<Link to="/" className="hover:text-BLACK">
|
||||||
@ -395,10 +389,48 @@ export const ProductDetailView = memo(function ProductDetailView({
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{/* Floating back button (mobile) */}
|
||||||
|
{!isOwner && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => safeGoBack(navigate)}
|
||||||
|
aria-label="بازگشت"
|
||||||
|
className="lg:hidden absolute z-20 right-4 w-10 h-10 rounded-full bg-WHITE/90 backdrop-blur shadow-md flex items-center justify-center text-BLACK"
|
||||||
|
style={{ top: "calc(env(safe-area-inset-top, 0px) + 12px)" }}
|
||||||
|
>
|
||||||
|
<ArrowRight size={22} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info */}
|
{/* Info */}
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
|
{/* Brand row (mobile) — moved out of the top bar, sits below image */}
|
||||||
|
{!isOwner && product.seller?.username ? (
|
||||||
|
<div className="lg:hidden flex items-center gap-3 px-4 mt-4">
|
||||||
|
<Link
|
||||||
|
to={`/seller/${product.seller.username}`}
|
||||||
|
className="flex items-center gap-2.5 min-w-0"
|
||||||
|
>
|
||||||
|
<SellerLogo
|
||||||
|
size="sm"
|
||||||
|
src={product.seller?.storeLogo}
|
||||||
|
alt="seller"
|
||||||
|
/>
|
||||||
|
<span className="font-bold text-[14px] truncate">
|
||||||
|
{product.seller.username}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleChat}
|
||||||
|
className="ms-auto shrink-0 h-9 px-4 rounded-full border border-BLACK text-BLACK text-[12px] font-bold flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<MessageCircle size={16} />
|
||||||
|
ارسال پیام
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{/* Seller (desktop) */}
|
{/* Seller (desktop) */}
|
||||||
{!isOwner && product.seller?.username ? (
|
{!isOwner && product.seller?.username ? (
|
||||||
<Link
|
<Link
|
||||||
@ -495,6 +527,28 @@ export const ProductDetailView = memo(function ProductDetailView({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile: rating (only when > 0) */}
|
||||||
|
{!isOwner && avgRating > 0 ? (
|
||||||
|
<div className="lg:hidden flex items-center gap-2 px-4 mt-3 text-[13px] text-BLACK2">
|
||||||
|
<span className="flex items-center">
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<Star
|
||||||
|
key={i}
|
||||||
|
size={15}
|
||||||
|
className={
|
||||||
|
i <= Math.round(avgRating)
|
||||||
|
? "fill-BLACK text-BLACK"
|
||||||
|
: "text-GRAY3"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<b className="text-BLACK">{avgRating.toFixed(1)}</b>
|
||||||
|
<span className="w-1 h-1 rounded-full bg-GRAY2" />
|
||||||
|
<span>{reviewCount} دیدگاه</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{hasAnyStock ? (
|
{hasAnyStock ? (
|
||||||
<>
|
<>
|
||||||
{hasColorVariants && (
|
{hasColorVariants && (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user