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:
Arda Samadi 2026-06-27 15:06:54 +03:30
parent b45e247dda
commit 692d385d09
4 changed files with 82 additions and 57 deletions

View File

@ -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">
<div className="flex flex-col gap-1">
<AddToCartButton
isOutOfStock={isOutOfStock}
onClick={handleAddToCart}
/>
</div>
<PriceDisplay <PriceDisplay
currentPrice={currentPrice} currentPrice={currentPrice}
currentDiscountPrice={currentDiscountPrice} currentDiscountPrice={currentDiscountPrice}
/> />
</div> <AddToCartButton isOutOfStock={isOutOfStock} onClick={handleAddToCart} />
</div> </div>
<CartDrawer <CartDrawer

View File

@ -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>

View File

@ -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}>

View File

@ -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 && (